feat: 实现20260730优化方案全部功能
- AI文件审查:.docx上传提取文本,支持多种文档类型 - 用工办理工作流:WorkProcess页面+后端API,支持入职/续签/终止等流程 - 企业自建文本库:Templates页面Tab切换,企业模板CRUD+渲染+下载Word - 考勤发布:Attendance发布/取消发布按钮,员工端MyAttendance页面 - 工资条发布:Money发布/定时发送按钮+弹窗,portal端publishStatus过滤 - 合同到期弹窗:Dashboard合同到期预警可点击打开弹窗,支持续签/终止操作 - Prisma schema新增WorkProcess/EnterpriseTemplate/AttendancePublish模型 - 前后端编译验证全部通过
This commit is contained in:
+540
@@ -0,0 +1,540 @@
|
||||
# TurboHR 优化方案(20260730)
|
||||
|
||||
> 日期:2026-07-30
|
||||
> 依据:白话用工侠完整运行测试报告对比分析 + 功能缺口梳理
|
||||
> 优先级:P0(紧急/核心)、P1(重要补全)、P2(增强优化)
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
将「白话用工侠完整运行测试报告」中列出的功能与本系统(TurboHR)实际代码逐一对比后,识别出以下功能缺口:
|
||||
|
||||
### 已实现功能(14项)
|
||||
|
||||
| 功能 | 对应文件 |
|
||||
|------|---------|
|
||||
| 登录与全局框架 | `Login.tsx`、`SidebarNav`、`TopNav` |
|
||||
| 工作台(合规健康度/风险预警/待办) | `Dashboard.tsx` |
|
||||
| AI 智能问答(流式/历史/语音/转律师) | `AIAssistant.tsx` ChatTab |
|
||||
| AI 判赔预测(12类争议场景) | `AIAssistant.tsx` PredictTab |
|
||||
| AI 合同审查(粘贴文本) | `AIAssistant.tsx` ReviewTab |
|
||||
| AI 案例匹配 | `AIAssistant.tsx` CaseTab |
|
||||
| 花名册(搜索/筛选/导入/导出/二维码邀请) | `Roster.tsx` |
|
||||
| 考勤管理(记录/导入/统计/排班/休假) | `Attendance.tsx` |
|
||||
| 合同管理(列表/查询/查看/下载/删除) | `Contracts.tsx` |
|
||||
| 规章制度民主程序(四步流程) | `Policies.tsx` |
|
||||
| 用工体检诊断(6维度评分) | `HealthCheck.tsx` |
|
||||
| 文本模板库(87模板/下载Word/变量渲染) | `Templates.tsx` |
|
||||
| 通知管理(发布/目标人群/定时) | `Notifications.tsx` |
|
||||
| 计算器(五险一金/医疗期) | `SocialInsurance.tsx`、`MedicalPeriodCalculator.tsx` |
|
||||
|
||||
### 部分实现功能(3项)
|
||||
|
||||
| 功能 | 已有部分 | 缺失部分 |
|
||||
|------|---------|---------|
|
||||
| AI 文件审查 | 粘贴合同文本审查、结构化风险报告 | 不支持上传 doc/docx 文件,无文书类型选择 |
|
||||
| 考勤管理 | 考勤记录导入、查看、统计 | 无"发布考勤表"功能 |
|
||||
| 工资条管理 | 工资条生成、员工端查看 | 无"发布工资条"和"定时发送"功能 |
|
||||
|
||||
### 未实现功能(5项)
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 用工办理工作流(13类流程) | 最大的功能缺口,无统一办理工作流系统 |
|
||||
| 背景调查 | 完全未实现(本次暂不纳入) |
|
||||
| 视频中心 | 完全未实现(本次暂不纳入) |
|
||||
| 企业自建文本库 | 无企业自建文本管理功能 |
|
||||
| 合同到期处理弹窗 | 有到期提醒但无多选项处理弹窗(本次暂不纳入) |
|
||||
|
||||
**本次优化聚焦4项功能**:用工办理工作流、企业自建文本库、考勤/工资条发布、AI文件审查上传。
|
||||
|
||||
---
|
||||
|
||||
## P0:AI文件审查上传功能
|
||||
|
||||
### 问题描述
|
||||
|
||||
现有 `AIAssistant.tsx` ReviewTab 仅支持粘贴合同文本进行审查,无法上传 doc/docx 文件。白话用工侠支持上传文件并选择文书类型(劳动合同/协商解除/劳务协议/实习协议/保密协议)。
|
||||
|
||||
### 涉及文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `backend/src/routes/ai.routes.ts` | 新增 `POST /ai/review/upload` 接口 |
|
||||
| `frontend/src/pages/AIAssistant.tsx` ReviewTab | 新增文件上传区 + 文书类型选择 |
|
||||
| `backend/package.json` | 新增 `mammoth` 依赖 |
|
||||
|
||||
### 实现方案
|
||||
|
||||
#### 后端:文件上传 + 文本提取
|
||||
|
||||
```typescript
|
||||
// ai.routes.ts 新增
|
||||
import mammoth from 'mammoth'
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase()
|
||||
if (ext !== '.docx' && ext !== '.doc') {
|
||||
return cb(new Error('仅支持 .doc 和 .docx 文件'))
|
||||
}
|
||||
cb(null, true)
|
||||
},
|
||||
})
|
||||
|
||||
router.post('/review/upload', authMiddleware, upload.single('file'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ success: false, error: { code: 'NO_FILE', message: '请上传文件' } })
|
||||
const ext = path.extname(req.file.originalname).toLowerCase()
|
||||
let text = ''
|
||||
if (ext === '.docx') {
|
||||
const result = await mammoth.extractRawText({ buffer: req.file.buffer })
|
||||
text = result.value
|
||||
} else {
|
||||
// .doc 旧格式:提示用户转换为 .docx
|
||||
return res.status(400).json({ success: false, error: { code: 'UNSUPPORTED', message: '暂不支持 .doc 格式,请将文件另存为 .docx 后上传' } })
|
||||
}
|
||||
// 截断超长文本
|
||||
if (text.length > 50000) {
|
||||
text = text.slice(0, 50000) + '\n\n[文本过长,已截断]'
|
||||
}
|
||||
res.json({ success: true, data: { text } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
```
|
||||
|
||||
#### 前端:ReviewTab 改造
|
||||
|
||||
在现有粘贴文本输入框上方新增:
|
||||
|
||||
```
|
||||
合同审查 Tab:
|
||||
├── 文书类型选择(下拉:劳动合同/协商解除协议/劳务协议/实习协议/保密协议)
|
||||
├── 文件上传区(拖拽或点击上传 .docx)
|
||||
│ ├── 上传后调用 /ai/review/upload 提取文本
|
||||
│ └── 提取成功后自动填入下方输入框
|
||||
├── 文本输入框(现有,用户可编辑提取后的文本)
|
||||
├── 开始审查按钮(现有)
|
||||
└── 审查结果展示(现有)
|
||||
```
|
||||
|
||||
#### 依赖安装
|
||||
|
||||
```bash
|
||||
cd backend && npm install mammoth
|
||||
```
|
||||
|
||||
### 安全考虑
|
||||
|
||||
- 文件大小限制:100MB(multer limits)
|
||||
- 文件类型校验:仅 `.docx`,`.doc` 提示转换
|
||||
- 提取后不保存原文件,仅返回文本
|
||||
- 文本长度截断:超过 50000 字符时截断并提示
|
||||
|
||||
---
|
||||
|
||||
## P0:用工办理工作流系统(13类流程)
|
||||
|
||||
### 问题描述
|
||||
|
||||
本系统有 `Termination.tsx`(离职管理)和 `Contracts.tsx`(合同管理),但缺少统一的用工办理工作流系统。白话用工侠提供13类办理流程:员工录用、员工入职、自定义合同签署、员工信息提交、员工转正、合同变更、合同续签、合同中止、开具收入证明、合同终止、合同解除、开具离职证明、灵活用工。
|
||||
|
||||
### 13类流程定义
|
||||
|
||||
| 编号 | 流程名称 | 类型代码 | 核心表单字段 | 关联模块 |
|
||||
|------|---------|---------|------------|---------|
|
||||
| 1 | 员工录用 | HIRE | 人员选择、入职时间、公司地址、部门、岗位、直属上级、联系方式、试用期薪酬、转正薪酬、携带材料 | 员工创建+合同起草 |
|
||||
| 2 | 员工入职 | ONBOARD | 入职日期、岗位确认、合同签署方式、材料提交清单 | 员工状态→ACTIVE |
|
||||
| 3 | 自定义合同签署 | CUSTOM_CONTRACT | 合同模板选择、变量填充、签署方、期限 | 合同管理 |
|
||||
| 4 | 员工信息提交 | INFO_SUBMIT | 信息变更字段、证明材料 | 员工档案更新 |
|
||||
| 5 | 员工转正 | CONFIRM | 转正日期、转正薪资、考核结果 | 员工状态+薪资变更 |
|
||||
| 6 | 合同变更 | CHANGE | 变更类型、变更内容、生效日期 | 合同管理 |
|
||||
| 7 | 合同续签 | RENEW | 续签次数、新期限、新薪资 | 合同管理 |
|
||||
| 8 | 合同中止 | SUSPEND | 中止原因、中止期限、预计恢复日期 | 合同状态 |
|
||||
| 9 | 开具收入证明 | INCOME_CERT | 用途、收入期间、接收方 | 文本模板渲染 |
|
||||
| 10 | 合同终止 | TERMINATE | 终止原因、终止日期、经济补偿 | 合同状态+离职 |
|
||||
| 11 | 合同解除 | RESCIND | 解除原因、解除方式、协商/单方 | 复用 Termination 模块 |
|
||||
| 12 | 开具离职证明 | LEAVING_CERT | 离职日期、离职原因、接收方 | 文本模板渲染 |
|
||||
| 13 | 灵活用工 | FLEXIBLE | 人员信息、用工类型、协议期限、计酬方式 | 合同管理(LABOR) |
|
||||
|
||||
### 数据模型
|
||||
|
||||
```prisma
|
||||
// 用工办理流程
|
||||
model WorkProcess {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
type String // HIRE/ONBOARD/CUSTOM_CONTRACT/INFO_SUBMIT/CONFIRM/CHANGE/RENEW/SUSPEND/INCOME_CERT/TERMINATE/RESCIND/LEAVING_CERT/FLEXIBLE
|
||||
title String
|
||||
employeeId String?
|
||||
employee Employee? @relation(fields: [employeeId], references: [id])
|
||||
status String @default("DRAFT") // DRAFT/PENDING_APPROVAL/APPROVED/REJECTED/EXECUTING/COMPLETED/CANCELLED
|
||||
formData Json // 表单数据 JSON
|
||||
documents Json? // 生成的文书列表 [{name, content, type}]
|
||||
approverId String?
|
||||
approvedAt DateTime?
|
||||
remark String?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([orgId, status])
|
||||
@@index([orgId, type])
|
||||
@@index([orgId, createdBy])
|
||||
}
|
||||
```
|
||||
|
||||
### 后端接口
|
||||
|
||||
```
|
||||
POST /api/v1/work-processes # 创建办理(含草稿)
|
||||
GET /api/v1/work-processes # 列表查询(支持type/status筛选)
|
||||
GET /api/v1/work-processes/:id # 详情
|
||||
PATCH /api/v1/work-processes/:id # 更新草稿
|
||||
POST /api/v1/work-processes/:id/submit # 提交办理
|
||||
POST /api/v1/work-processes/:id/approve # 审批通过
|
||||
POST /api/v1/work-processes/:id/reject # 驳回
|
||||
POST /api/v1/work-processes/:id/cancel # 撤销
|
||||
DELETE /api/v1/work-processes/:id # 删除草稿
|
||||
GET /api/v1/work-processes/:id/preview # 预览生成的文书
|
||||
```
|
||||
|
||||
### 前端架构
|
||||
|
||||
**新增页面**:`frontend/src/pages/WorkProcess.tsx`
|
||||
|
||||
```
|
||||
页面结构:
|
||||
├── 发起办理(13类流程卡片选择)
|
||||
├── 办理记录(列表+筛选:类型/状态/日期)
|
||||
├── 办理草稿(仅 DRAFT 状态)
|
||||
└── 流程详情(步骤条+表单+预览+操作)
|
||||
```
|
||||
|
||||
**流程详情组件**(通用 Wizard):
|
||||
- 步骤条:填写信息 → 预览文书 → 存草稿/提交
|
||||
- 表单根据 `type` 动态渲染字段
|
||||
- 预览:调用文本模板渲染接口生成文书
|
||||
- 提交后根据流程类型执行对应业务逻辑
|
||||
|
||||
**复用已有模块**:
|
||||
- 合同解除(#11)→ 复用 `Termination.tsx` 的 Wizard 逻辑
|
||||
- 合同续签(#7)→ 复用 `Contracts.tsx` 的合同管理逻辑
|
||||
- 收入证明(#9)/ 离职证明(#12)→ 复用 `Templates.tsx` 的模板渲染
|
||||
- 员工录用(#1)→ 复用 `roster/modals.tsx` 的添加员工逻辑
|
||||
|
||||
### 提交后业务联动
|
||||
|
||||
| 流程类型 | 提交后执行 |
|
||||
|---------|-----------|
|
||||
| HIRE | 创建员工记录 + 创建劳动合同 |
|
||||
| ONBOARD | 更新员工状态为 ACTIVE + 记录入职日期 |
|
||||
| CUSTOM_CONTRACT | 创建劳动合同 |
|
||||
| INFO_SUBMIT | 更新员工档案字段 |
|
||||
| CONFIRM | 更新试用期结束 + 调整薪资 |
|
||||
| CHANGE | 更新合同字段 + 记录变更历史 |
|
||||
| RENEW | 关闭旧合同 + 创建新合同 |
|
||||
| SUSPEND | 合同状态改为 SUSPENDED |
|
||||
| INCOME_CERT | 生成收入证明文书(不改变业务数据) |
|
||||
| TERMINATE | 合同状态改为 TERMINATED + 触发离职流程 |
|
||||
| RESCIND | 调用已有 Termination 逻辑 |
|
||||
| LEAVING_CERT | 生成离职证明文书 |
|
||||
| FLEXIBLE | 创建劳务协议合同 |
|
||||
|
||||
### 涉及文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `backend/prisma/schema.prisma` | 新增 `WorkProcess` 模型 |
|
||||
| `backend/src/routes/work-process.routes.ts` | **新增**,办理 CRUD + 提交/审批/撤销 |
|
||||
| `backend/src/services/work-process.service.ts` | **新增**,13类流程的业务联动逻辑 |
|
||||
| `backend/src/index.ts` | 注册新路由 |
|
||||
| `frontend/src/pages/WorkProcess.tsx` | **新增**,办理页面 |
|
||||
| `frontend/src/App.tsx` | 注册路由 |
|
||||
| `frontend/src/components/layout/SidebarNav.tsx` | 新增菜单项 |
|
||||
|
||||
### 实施分阶段
|
||||
|
||||
1. **Phase 1**:数据模型 + 基础 CRUD + 列表/草稿页面
|
||||
2. **Phase 2**:员工录用、员工入职、合同续签、合同终止(4个高频流程)
|
||||
3. **Phase 3**:剩余9类流程 + 文书预览生成
|
||||
4. **Phase 4**:审批流程 + 办理记录导出
|
||||
|
||||
---
|
||||
|
||||
## P1:企业自建文本库
|
||||
|
||||
### 问题描述
|
||||
|
||||
现有 `Templates.tsx` 仅提供系统预置模板(87个,只读),企业无法创建和管理自己的文本模板。白话用工侠有"企业文本库"功能,支持自建文本。
|
||||
|
||||
### 数据模型
|
||||
|
||||
```prisma
|
||||
// 企业自建文本模板
|
||||
model EnterpriseTemplate {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
category String // CONTRACT/RULES/NOTICE/AGREEMENT/OTHER
|
||||
description String?
|
||||
content String @db.Text
|
||||
variables String[] // 变量名列表
|
||||
status String @default("ACTIVE") // ACTIVE/ARCHIVED
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([orgId, category])
|
||||
}
|
||||
```
|
||||
|
||||
### 后端接口
|
||||
|
||||
```
|
||||
# 现有模板路由:/api/v1/templates(系统预置,只读)
|
||||
# 新增企业模板路由:/api/v1/enterprise-templates
|
||||
|
||||
GET /api/v1/enterprise-templates # 列表(支持category/search)
|
||||
POST /api/v1/enterprise-templates # 新建
|
||||
GET /api/v1/enterprise-templates/:id # 详情
|
||||
PUT /api/v1/enterprise-templates/:id # 更新
|
||||
DELETE /api/v1/enterprise-templates/:id # 删除
|
||||
POST /api/v1/enterprise-templates/:id/render # 渲染(复用现有 renderTemplate 逻辑)
|
||||
GET /api/v1/enterprise-templates/:id/download # 下载Word
|
||||
```
|
||||
|
||||
### 前端方案
|
||||
|
||||
**改造页面**:`frontend/src/pages/Templates.tsx`
|
||||
|
||||
在现有模板库页面顶部新增 Tab 切换:
|
||||
- **系统模板库**(现有功能不变)
|
||||
- **企业文本库**(新增)
|
||||
|
||||
企业文本库 Tab 内容:
|
||||
```
|
||||
├── 查询栏(名称搜索 + 分类筛选)
|
||||
├── 新建/编辑弹窗(名称、分类、变量配置、内容编辑)
|
||||
├── 模板卡片列表(复用现有卡片样式)
|
||||
├── 详情弹窗(变量填写 → 渲染 → 下载Word/复制)
|
||||
└── 操作:编辑、复制、删除、归档
|
||||
```
|
||||
|
||||
### 涉及文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `backend/prisma/schema.prisma` | 新增 `EnterpriseTemplate` 模型 |
|
||||
| `backend/src/routes/enterprise-template.routes.ts` | **新增**,CRUD + 渲染 + 下载 |
|
||||
| `backend/src/index.ts` | 注册新路由 |
|
||||
| `frontend/src/pages/Templates.tsx` | 顶部新增 Tab 切换 + 企业文本库面板 |
|
||||
|
||||
### 实现要点
|
||||
|
||||
- 变量提取:编辑内容时自动扫描 `{{变量名}}` 提取变量列表
|
||||
- 渲染逻辑:复用 `template.service.ts` 的 `renderTemplate` 函数
|
||||
- 内容编辑器:使用 textarea + 变量插入按钮(点击插入 `{{变量名}}`)
|
||||
- 权限:ADMIN/HR 可增删改,VIEWER 只读
|
||||
|
||||
---
|
||||
|
||||
## P1:考勤发布流程
|
||||
|
||||
### 问题描述
|
||||
|
||||
现有 `Attendance.tsx` 有考勤记录和统计功能,但缺少"发布考勤表"功能。白话用工侠支持 HR 发布月度考勤表,员工在员工端查看确认。
|
||||
|
||||
### 数据模型
|
||||
|
||||
```prisma
|
||||
// 考勤发布记录
|
||||
model AttendancePublish {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
month String // 2026-07
|
||||
title String // 如"2026年7月考勤表"
|
||||
status String @default("PUBLISHED") // PUBLISHED/CANCELLED
|
||||
publishDate DateTime @default(now())
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([orgId, month])
|
||||
@@index([orgId, month])
|
||||
}
|
||||
```
|
||||
|
||||
### 后端接口
|
||||
|
||||
```
|
||||
POST /api/v1/attendance/publish # 发布考勤表
|
||||
GET /api/v1/attendance/publish-records # 发布记录列表
|
||||
POST /api/v1/attendance/publish/:id/cancel # 取消发布
|
||||
GET /api/v1/portal/attendance?month=2026-07 # 员工查看自己的月度考勤
|
||||
```
|
||||
|
||||
### 前端改造
|
||||
|
||||
**`Attendance.tsx`** — 在「考勤确认」Tab 工具栏新增:
|
||||
- **「发布考勤表」按钮**:弹窗确认 → 选择月份 → 发布
|
||||
- **「发布记录」入口**:查看历史发布记录,可取消发布
|
||||
|
||||
**`portal/` 新增考勤查看页面** — `portal/MyAttendance.tsx`:
|
||||
- 员工登录后查看已发布月份的考勤记录
|
||||
- 显示每日考勤状态、上下班时间
|
||||
- 支持月度切换
|
||||
|
||||
### 涉及文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `backend/prisma/schema.prisma` | 新增 `AttendancePublish` 模型 |
|
||||
| `backend/src/routes/attendance.routes.ts` | 新增发布/取消/记录接口 |
|
||||
| `backend/src/routes/portal.routes.ts` | 新增员工端考勤查看接口 |
|
||||
| `frontend/src/pages/Attendance.tsx` | 新增发布按钮和发布记录弹窗 |
|
||||
| `frontend/src/pages/portal/MyAttendance.tsx` | **新增**,员工端考勤查看 |
|
||||
| `frontend/src/App.tsx` | 注册员工端考勤路由 |
|
||||
| `frontend/src/components/layout/PortalLayout.tsx` | 员工端导航新增考勤入口 |
|
||||
|
||||
---
|
||||
|
||||
## P1:工资条发布与定时发送
|
||||
|
||||
### 问题描述
|
||||
|
||||
现有 `Money.tsx` 可生成工资条,员工端 `portal/Payslip.tsx` 可查看,但缺少"发布工资条"和"定时发送"功能。白话用工侠支持 HR 发布工资条后员工才能看到,并支持定时发送。
|
||||
|
||||
### 数据模型
|
||||
|
||||
复用已有 `Payslip` 模型,新增发布状态字段:
|
||||
|
||||
```prisma
|
||||
// 在 Payslip 模型新增字段
|
||||
model Payslip {
|
||||
// ...已有字段
|
||||
publishStatus String? // UNPUBLISHED/PUBLISHED/SCHEDULED
|
||||
publishedAt DateTime?
|
||||
scheduledAt DateTime? // 定时发送时间
|
||||
// confirmedAt 已有
|
||||
}
|
||||
```
|
||||
|
||||
### 后端接口
|
||||
|
||||
```
|
||||
POST /api/v1/payroll/batches/:batchId/publish # 发布工资条
|
||||
POST /api/v1/payroll/batches/:batchId/schedule # 定时发送
|
||||
GET /api/v1/payroll/schedule-records # 定时发送记录
|
||||
POST /api/v1/payroll/schedule/:id/cancel # 取消定时发送
|
||||
```
|
||||
|
||||
### 前端改造
|
||||
|
||||
**`Money.tsx`** — 在批次详情页新增:
|
||||
- **「发布工资条」按钮**:将批次内所有工资条标记为 PUBLISHED
|
||||
- **「定时发送」选项**:选择发送时间,到点自动发布
|
||||
- **「定时发送记录」入口**:查看定时发送列表,可取消
|
||||
|
||||
**`portal/Payslip.tsx`** — 调整查询逻辑:
|
||||
- 发布前:员工端不显示该月工资条
|
||||
- 发布后:员工端显示工资条,可查看和确认
|
||||
- 定时发送:到点后状态从 SCHEDULED → PUBLISHED
|
||||
|
||||
### 定时发送实现
|
||||
|
||||
- 使用 `node-cron` 或现有定时任务机制
|
||||
- 每分钟检查 `scheduledAt <= now && publishStatus = SCHEDULED` 的记录
|
||||
- 自动更新为 PUBLISHED 并发送通知
|
||||
|
||||
### 涉及文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `backend/prisma/schema.prisma` | `Payslip` 模型新增 `publishStatus`/`publishedAt`/`scheduledAt` 字段 |
|
||||
| `backend/src/routes/payroll.routes.ts` 或 `payroll2.routes.ts` | 新增发布/定时发送/取消接口 |
|
||||
| `backend/src/index.ts` | 注册定时任务 |
|
||||
| `frontend/src/pages/Money.tsx` | 批次详情页新增发布/定时发送按钮 |
|
||||
| `frontend/src/pages/portal/Payslip.tsx` | 查询逻辑增加 publishStatus 过滤 |
|
||||
| `backend/src/routes/portal.routes.ts` | 员工端 payslip 接口增加 publishStatus 过滤 |
|
||||
|
||||
---
|
||||
|
||||
## P2:合同到期处理弹窗(增强)
|
||||
|
||||
### 问题描述
|
||||
|
||||
现有 `Dashboard.tsx` 有合同到期提醒,但仅显示列表。白话用工侠支持到期合同弹窗处理:发送续签通知、已线下续签、终止合同、自定义签署,并可查看合同PDF。
|
||||
|
||||
### 前端改造
|
||||
|
||||
**`Dashboard.tsx`** — 合同到期待办项增加操作弹窗:
|
||||
|
||||
```
|
||||
合同到期处理弹窗:
|
||||
├── 员工信息 + 合同信息(类型/期限/到期日)
|
||||
├── 合同PDF查看链接(如有附件)
|
||||
└── 操作按钮:
|
||||
├── 发送续签通知 → 调用通知接口
|
||||
├── 已线下续签 → 更新合同状态 + 创建新合同记录
|
||||
├── 终止合同 → 跳转 Termination 模块
|
||||
└── 自定义签署 → 跳转 WorkProcess CUSTOM_CONTRACT
|
||||
```
|
||||
|
||||
### 涉及文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `frontend/src/pages/Dashboard.tsx` | 合同到期待办增加操作弹窗 |
|
||||
| `backend/src/routes/dashboard.routes.ts` | 如需新增批量操作接口 |
|
||||
|
||||
---
|
||||
|
||||
## 实施计划
|
||||
|
||||
### 优先级排序
|
||||
|
||||
| 优先级 | 功能 | 预估工作量 | 建议时间 |
|
||||
|--------|------|-----------|---------|
|
||||
| P0 | AI文件审查上传 | 1-2天 | 立即 |
|
||||
| P0 | 用工办理工作流 Phase 1-2 | 5-7天 | 本周 |
|
||||
| P1 | 企业自建文本库 | 2-3天 | 下周 |
|
||||
| P1 | 考勤发布 | 2天 | 下周 |
|
||||
| P1 | 工资条发布与定时发送 | 2-3天 | 下周 |
|
||||
| P0 | 用工办理工作流 Phase 3-4 | 5-7天 | 第三周 |
|
||||
| P2 | 合同到期处理弹窗 | 1-2天 | 第三周 |
|
||||
|
||||
### 分周计划
|
||||
|
||||
- **第1周**:AI文件审查上传 + 用工办理 Phase 1-2(数据模型 + CRUD + 4个高频流程)
|
||||
- **第2周**:企业文本库 + 考勤发布 + 工资条发布
|
||||
- **第3周**:用工办理 Phase 3-4(剩余9类流程 + 审批) + 合同到期弹窗 + 联调测试
|
||||
|
||||
### 总工作量
|
||||
|
||||
约 18-26 个工作日
|
||||
|
||||
### 数据库迁移
|
||||
|
||||
所有新增模型和字段变更需要执行:
|
||||
```bash
|
||||
cd backend && npx prisma db push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 风险与注意事项
|
||||
|
||||
1. **用工办理工作流**是最复杂的功能,建议先实现4个高频流程(录用/入职/续签/终止),验证架构后再扩展剩余9类
|
||||
2. **企业文本库**的变量提取逻辑需与系统模板保持一致,复用 `renderTemplate` 函数
|
||||
3. **工资条发布**涉及薪资敏感数据,需确保只有发布后员工端才能看到,发布前 `publishStatus = UNPUBLISHED` 的记录在员工端不可见
|
||||
4. **AI文件审查**的 .doc 旧格式支持有限,建议仅支持 .docx 并提示用户转换
|
||||
5. **定时发送**需要确保服务器进程持续运行(PM2 已有保障),定时任务需做幂等处理防止重复发布
|
||||
6. **用工办理工作流**的13类流程提交后业务联动逻辑较复杂,每类流程需独立编写 `executeWorkProcess(type, formData)` 逻辑
|
||||
7. **考勤发布**需考虑已取消发布的月份是否允许重新发布(`@@unique([orgId, month])` 约束需处理)
|
||||
8. **合同到期弹窗**的"发送续签通知"需复用现有通知模块 `Notifications.tsx` 的逻辑
|
||||
Generated
+96
@@ -19,6 +19,7 @@
|
||||
"express-rate-limit": "^7.4.0",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mammoth": "^1.12.0",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.2.0",
|
||||
"node-cron": "^3.0.3",
|
||||
@@ -944,6 +945,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.13",
|
||||
"resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
|
||||
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz",
|
||||
@@ -1097,6 +1107,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@@ -1638,6 +1657,21 @@
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dingbat-to-unicode": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
|
||||
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/duck": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmmirror.com/duck/-/duck-0.1.12.tgz",
|
||||
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
|
||||
"license": "BSD",
|
||||
"dependencies": {
|
||||
"underscore": "^1.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -2576,6 +2610,17 @@
|
||||
"integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lop": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmmirror.com/lop/-/lop-0.4.2.tgz",
|
||||
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"duck": "^0.1.12",
|
||||
"option": "~0.2.1",
|
||||
"underscore": "^1.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/make-error": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmmirror.com/make-error/-/make-error-1.3.6.tgz",
|
||||
@@ -2583,6 +2628,30 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/mammoth": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.0.tgz",
|
||||
"integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@xmldom/xmldom": "^0.8.6",
|
||||
"argparse": "~1.0.3",
|
||||
"base64-js": "^1.5.1",
|
||||
"bluebird": "~3.4.0",
|
||||
"dingbat-to-unicode": "^1.0.1",
|
||||
"jszip": "^3.7.1",
|
||||
"lop": "^0.4.2",
|
||||
"path-is-absolute": "^1.0.0",
|
||||
"underscore": "^1.13.1",
|
||||
"xmlbuilder": "^10.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"mammoth": "bin/mammoth"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -2860,6 +2929,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/option": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/option/-/option-0.2.4.tgz",
|
||||
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz",
|
||||
@@ -3297,6 +3372,12 @@
|
||||
"source-map": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz",
|
||||
@@ -3578,6 +3659,12 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/underscore": {
|
||||
"version": "1.13.8",
|
||||
"resolved": "https://registry.npmmirror.com/underscore/-/underscore-1.13.8.tgz",
|
||||
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
|
||||
@@ -3730,6 +3817,15 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder": {
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
|
||||
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"express-rate-limit": "^7.4.0",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mammoth": "^1.12.0",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.2.0",
|
||||
"node-cron": "^3.0.3",
|
||||
|
||||
@@ -172,6 +172,9 @@ model Organization {
|
||||
leaveRecords LeaveRecord[]
|
||||
calendarEvents CalendarEvent[]
|
||||
consultations Consultation[]
|
||||
workProcesses WorkProcess[]
|
||||
enterpriseTemplates EnterpriseTemplate[]
|
||||
attendancePublishes AttendancePublish[]
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -252,6 +255,7 @@ model Employee {
|
||||
shiftAssignments ShiftAssignment[]
|
||||
leaveRecords LeaveRecord[]
|
||||
calendarEvents CalendarEvent[]
|
||||
workProcesses WorkProcess[]
|
||||
|
||||
@@unique([orgId, idCardHash])
|
||||
}
|
||||
@@ -628,6 +632,8 @@ model Payslip {
|
||||
confirmedAt DateTime?
|
||||
confirmedIp String?
|
||||
publishedAt DateTime? // 工资条发布到员工端的时间
|
||||
publishStatus String? // UNPUBLISHED/PUBLISHED/SCHEDULED
|
||||
scheduledAt DateTime? // 定时发送时间
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -1171,3 +1177,61 @@ model Consultation {
|
||||
@@index([orgId, status])
|
||||
@@index([orgId, type])
|
||||
}
|
||||
|
||||
// ========== 用工办理工作流 ==========
|
||||
model WorkProcess {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
type String // HIRE/ONBOARD/CUSTOM_CONTRACT/INFO_SUBMIT/CONFIRM/CHANGE/RENEW/SUSPEND/INCOME_CERT/TERMINATE/RESCIND/LEAVING_CERT/FLEXIBLE
|
||||
title String
|
||||
employeeId String?
|
||||
employee Employee? @relation(fields: [employeeId], references: [id], onDelete: SetNull)
|
||||
status String @default("DRAFT") // DRAFT/PENDING_APPROVAL/APPROVED/REJECTED/EXECUTING/COMPLETED/CANCELLED
|
||||
formData Json // 表单数据 JSON
|
||||
documents Json? // 生成的文书列表 [{name, content, type}]
|
||||
approverId String?
|
||||
approvedAt DateTime?
|
||||
remark String?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([orgId, status])
|
||||
@@index([orgId, type])
|
||||
@@index([orgId, createdBy])
|
||||
}
|
||||
|
||||
// ========== 企业自建文本模板 ==========
|
||||
model EnterpriseTemplate {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
category String // CONTRACT/RULES/NOTICE/AGREEMENT/OTHER
|
||||
description String?
|
||||
content String @db.Text
|
||||
variables String[] // 变量名列表
|
||||
status String @default("ACTIVE") // ACTIVE/ARCHIVED
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([orgId, category])
|
||||
}
|
||||
|
||||
// ========== 考勤发布记录 ==========
|
||||
model AttendancePublish {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
month String // YYYY-MM
|
||||
title String // 如"2026年7月考勤表"
|
||||
status String @default("PUBLISHED") // PUBLISHED/CANCELLED
|
||||
publishDate DateTime @default(now())
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([orgId, month])
|
||||
@@index([orgId, month])
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ import templateRoutes from './routes/template.routes'
|
||||
import auditRoutes from './routes/audit.routes'
|
||||
import calendarRoutes from './routes/calendar.routes'
|
||||
import platformRoutes from './routes/platform.routes'
|
||||
import workProcessRoutes from './routes/work-process.routes'
|
||||
import enterpriseTemplateRoutes from './routes/enterprise-template.routes'
|
||||
app.use('/api/v1/auth', authRoutes)
|
||||
app.use('/api/v1/dashboard', dashboardRoutes)
|
||||
app.use('/api/v1/employees', employeeRoutes)
|
||||
@@ -80,6 +82,8 @@ app.use('/api/v1/templates', templateRoutes)
|
||||
app.use('/api/v1/audit', auditRoutes)
|
||||
app.use('/api/v1/calendar', calendarRoutes)
|
||||
app.use('/api/v1/platform', platformRoutes)
|
||||
app.use('/api/v1/work-processes', workProcessRoutes)
|
||||
app.use('/api/v1/enterprise-templates', enterpriseTemplateRoutes)
|
||||
|
||||
app.use(errorHandler)
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import { seedKnowledgeBase, addKnowledge, searchKnowledge, ensureRAGTable, searc
|
||||
import prisma from '../lib/prisma'
|
||||
import { z } from 'zod'
|
||||
import { decrypt } from '../lib/crypto'
|
||||
import multer from 'multer'
|
||||
import path from 'path'
|
||||
import mammoth from 'mammoth'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -947,6 +950,42 @@ ${expiringContracts.length > 0 ? expiringContracts.join('\n') : '无'}`
|
||||
}
|
||||
})
|
||||
|
||||
// ========== AI 文件审查上传 ==========
|
||||
|
||||
const reviewUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 100 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase()
|
||||
if (ext !== '.docx' && ext !== '.doc') {
|
||||
return cb(null, false)
|
||||
}
|
||||
cb(null, true)
|
||||
},
|
||||
})
|
||||
|
||||
router.post('/review/upload', authMiddleware, reviewUpload.single('file'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ success: false, error: { code: 'NO_FILE', message: '请上传 .docx 文件' } })
|
||||
}
|
||||
const ext = path.extname(req.file.originalname).toLowerCase()
|
||||
let text = ''
|
||||
if (ext === '.docx') {
|
||||
const result = await mammoth.extractRawText({ buffer: req.file.buffer })
|
||||
text = result.value
|
||||
} else {
|
||||
return res.status(400).json({ success: false, error: { code: 'UNSUPPORTED', message: '暂不支持 .doc 格式,请将文件另存为 .docx 后上传' } })
|
||||
}
|
||||
if (text.length > 50000) {
|
||||
text = text.slice(0, 50000) + '\n\n[文本过长,已截断]'
|
||||
}
|
||||
res.json({ success: true, data: { text, fileName: req.file.originalname } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 人工咨询服务 ==========
|
||||
|
||||
router.post('/consultation', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
deleteLeaveRecord,
|
||||
} from '../services/attendance.service'
|
||||
import { createEvidence } from '../services/evidence.service'
|
||||
import prisma from '../lib/prisma'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -238,4 +239,76 @@ router.delete('/leaves/:id', authMiddleware, async (req: AuthRequest, res: Respo
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ========== 考勤发布 ==========
|
||||
|
||||
// 发布考勤表
|
||||
router.post('/publish', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month, title } = req.body
|
||||
if (!month) {
|
||||
return res.status(400).json({ success: false, error: { code: 'MISSING_MONTH', message: '请选择月份' } })
|
||||
}
|
||||
// 检查是否已发布且未取消
|
||||
const existing = await (prisma as any).attendancePublish.findFirst({
|
||||
where: { orgId: req.user!.orgId, month, status: 'PUBLISHED' },
|
||||
})
|
||||
if (existing) {
|
||||
return res.status(400).json({ success: false, error: { code: 'ALREADY_PUBLISHED', message: `${month}月考勤表已发布` } })
|
||||
}
|
||||
// 如果有已取消的记录,删除后重新创建
|
||||
const cancelled = await (prisma as any).attendancePublish.findFirst({
|
||||
where: { orgId: req.user!.orgId, month, status: 'CANCELLED' },
|
||||
})
|
||||
if (cancelled) {
|
||||
await (prisma as any).attendancePublish.delete({ where: { id: cancelled.id } })
|
||||
}
|
||||
const record = await (prisma as any).attendancePublish.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
month,
|
||||
title: title || `${month}月考勤表`,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 发布记录列表
|
||||
router.get('/publish-records', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const records = await (prisma as any).attendancePublish.findMany({
|
||||
where: { orgId: req.user!.orgId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 取消发布
|
||||
router.post('/publish/:id/cancel', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const record = await (prisma as any).attendancePublish.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '发布记录不存在' } })
|
||||
}
|
||||
if (record.status !== 'PUBLISHED') {
|
||||
return res.status(400).json({ success: false, error: { code: 'NOT_PUBLISHED', message: '仅已发布状态可取消' } })
|
||||
}
|
||||
const updated = await (prisma as any).attendancePublish.update({
|
||||
where: { id: record.id },
|
||||
data: { status: 'CANCELLED' },
|
||||
})
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import prisma from '../lib/prisma'
|
||||
import { renderTemplate } from '../services/template.service'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 提取变量名
|
||||
function extractVariables(content: string): string[] {
|
||||
const matches = content.match(/\{\{(\w+)\}\}/g) || []
|
||||
return [...new Set(matches.map(m => m.replace(/\{\{|\}\}/g, '')))]
|
||||
}
|
||||
|
||||
// 列表
|
||||
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { category, search } = req.query
|
||||
const where: any = { orgId: req.user!.orgId, status: 'ACTIVE' }
|
||||
if (category) where.category = category
|
||||
if (search) where.name = { contains: String(search) }
|
||||
const items = await (prisma as any).enterpriseTemplate.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: items })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 新建
|
||||
router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { name, category, description, content } = req.body
|
||||
if (!name || !category || !content) {
|
||||
return res.status(400).json({ success: false, error: { code: 'MISSING_FIELDS', message: '名称、分类、内容为必填' } })
|
||||
}
|
||||
const variables = extractVariables(content)
|
||||
const template = await (prisma as any).enterpriseTemplate.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
name,
|
||||
category,
|
||||
description: description || null,
|
||||
content,
|
||||
variables,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: template })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 详情
|
||||
router.get('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const template = await (prisma as any).enterpriseTemplate.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!template) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
|
||||
}
|
||||
res.json({ success: true, data: template })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 更新
|
||||
router.put('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const existing = await (prisma as any).enterpriseTemplate.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!existing) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
|
||||
}
|
||||
const { name, category, description, content, status } = req.body
|
||||
const variables = content ? extractVariables(content) : existing.variables
|
||||
const updated = await (prisma as any).enterpriseTemplate.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
...(name !== undefined && { name }),
|
||||
...(category !== undefined && { category }),
|
||||
...(description !== undefined && { description }),
|
||||
...(content !== undefined && { content, variables }),
|
||||
...(status !== undefined && { status }),
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 删除
|
||||
router.delete('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const existing = await (prisma as any).enterpriseTemplate.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!existing) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
|
||||
}
|
||||
await (prisma as any).enterpriseTemplate.delete({ where: { id: req.params.id } })
|
||||
res.json({ success: true, data: { message: '已删除' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 渲染
|
||||
router.post('/:id/render', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const template = await (prisma as any).enterpriseTemplate.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!template) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
|
||||
}
|
||||
const { variables } = req.body as { variables: Record<string, string> }
|
||||
let content = template.content
|
||||
for (const [key, value] of Object.entries(variables || {})) {
|
||||
content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value)
|
||||
}
|
||||
res.json({ success: true, data: { content } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 下载 Word
|
||||
router.get('/:id/download', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const template = await (prisma as any).enterpriseTemplate.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!template) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
|
||||
}
|
||||
const encoded = encodeURIComponent(template.name + '.doc')
|
||||
res.setHeader('Content-Type', 'application/msword')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`)
|
||||
res.send(template.content)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -870,4 +870,99 @@ router.get('/batches/:batchId/pre-check', async (req: AuthRequest, res: Response
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 工资条发布 ==========
|
||||
|
||||
// 发布工资条(将批次内所有工资条标记为 PUBLISHED)
|
||||
router.post('/batches/:batchId/publish', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { batchId } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
|
||||
if (!batch) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
|
||||
}
|
||||
// 查找该批次关联的所有工资条(通过 BatchEntry 关联的 employeeId + month)
|
||||
const entries = await prisma.batchEntry.findMany({
|
||||
where: { batchId, orgId },
|
||||
select: { employeeId: true },
|
||||
})
|
||||
const employeeIds = entries.map(e => e.employeeId)
|
||||
if (employeeIds.length === 0) {
|
||||
return res.status(400).json({ success: false, error: { code: 'EMPTY', message: '批次内无员工' } })
|
||||
}
|
||||
// 更新对应月份的工资条
|
||||
const result = await prisma.payslip.updateMany({
|
||||
where: { orgId, employeeId: { in: employeeIds }, month: batch.month },
|
||||
data: { publishStatus: 'PUBLISHED', publishedAt: new Date() },
|
||||
})
|
||||
res.json({ success: true, data: { published: result.count, month: batch.month } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 定时发送工资条
|
||||
router.post('/batches/:batchId/schedule', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { batchId } = req.params
|
||||
const { scheduledAt } = req.body
|
||||
if (!scheduledAt) {
|
||||
return res.status(400).json({ success: false, error: { code: 'MISSING_DATE', message: '请选择发送时间' } })
|
||||
}
|
||||
const orgId = req.user!.orgId
|
||||
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
|
||||
if (!batch) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
|
||||
}
|
||||
const entries = await prisma.batchEntry.findMany({
|
||||
where: { batchId, orgId },
|
||||
select: { employeeId: true },
|
||||
})
|
||||
const employeeIds = entries.map(e => e.employeeId)
|
||||
if (employeeIds.length === 0) {
|
||||
return res.status(400).json({ success: false, error: { code: 'EMPTY', message: '批次内无员工' } })
|
||||
}
|
||||
const result = await prisma.payslip.updateMany({
|
||||
where: { orgId, employeeId: { in: employeeIds }, month: batch.month },
|
||||
data: { publishStatus: 'SCHEDULED', scheduledAt: new Date(scheduledAt) },
|
||||
})
|
||||
res.json({ success: true, data: { scheduled: result.count, scheduledAt } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 定时发送记录
|
||||
router.get('/schedule-records', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const records = await prisma.payslip.findMany({
|
||||
where: { orgId: req.user!.orgId, publishStatus: 'SCHEDULED' },
|
||||
include: { employee: { select: { name: true, department: true } } },
|
||||
orderBy: { scheduledAt: 'asc' },
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 取消定时发送
|
||||
router.post('/schedule/:id/cancel', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const payslip = await prisma.payslip.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId, publishStatus: 'SCHEDULED' },
|
||||
})
|
||||
if (!payslip) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '定时发送记录不存在' } })
|
||||
}
|
||||
const updated = await prisma.payslip.update({
|
||||
where: { id: payslip.id },
|
||||
data: { publishStatus: 'UNPUBLISHED', scheduledAt: null },
|
||||
})
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -110,7 +110,7 @@ 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 },
|
||||
where: { employeeId: req.employee.id, orgId: req.employee.orgId, month, publishStatus: 'PUBLISHED' },
|
||||
})
|
||||
if (!payslip) {
|
||||
return res.json({ success: true, data: null })
|
||||
@@ -125,7 +125,7 @@ router.get('/payslip', portalAuth, async (req: any, res, next) => {
|
||||
router.get('/payslip/history', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const payslips = await prisma.payslip.findMany({
|
||||
where: { employeeId: req.employee.id, orgId: req.employee.orgId },
|
||||
where: { employeeId: req.employee.id, orgId: req.employee.orgId, publishStatus: 'PUBLISHED' },
|
||||
orderBy: { month: 'desc' },
|
||||
take: 6,
|
||||
})
|
||||
@@ -611,4 +611,33 @@ router.get('/auto-login', async (req, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 员工端查看自己的月度考勤
|
||||
router.get('/attendance', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const month = req.query.month as string || new Date().toISOString().slice(0, 7)
|
||||
// 检查该月份是否已发布
|
||||
const publish = await (prisma as any).attendancePublish.findFirst({
|
||||
where: { orgId: req.employee.orgId, month, status: 'PUBLISHED' },
|
||||
})
|
||||
if (!publish) {
|
||||
return res.json({ success: true, data: { published: false, records: [] } })
|
||||
}
|
||||
// 查询该月考勤记录
|
||||
const startDate = new Date(`${month}-01`)
|
||||
const endDate = new Date(startDate)
|
||||
endDate.setMonth(endDate.getMonth() + 1)
|
||||
const records = await prisma.attendanceRecord.findMany({
|
||||
where: {
|
||||
employeeId: req.employee.id,
|
||||
orgId: req.employee.orgId,
|
||||
date: { gte: startDate, lt: endDate },
|
||||
},
|
||||
orderBy: { date: 'asc' },
|
||||
})
|
||||
res.json({ success: true, data: { published: true, records, title: publish.title } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import prisma from '../lib/prisma'
|
||||
import { executeWorkProcess, generateDocument, PROCESS_TYPES, PROCESS_STATUS } from '../services/work-process.service'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 创建办理(含草稿)
|
||||
router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { type, title, employeeId, formData, status = 'DRAFT', remark } = req.body
|
||||
if (!type || !PROCESS_TYPES[type]) {
|
||||
return res.status(400).json({ success: false, error: { code: 'INVALID_TYPE', message: '无效的流程类型' } })
|
||||
}
|
||||
const process = await (prisma as any).workProcess.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
type,
|
||||
title: title || PROCESS_TYPES[type].label,
|
||||
employeeId: employeeId || null,
|
||||
formData: formData || {},
|
||||
status,
|
||||
remark: remark || null,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: process })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 列表查询
|
||||
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { type, status, page = '1', pageSize = '20' } = req.query
|
||||
const where: any = { orgId: req.user!.orgId }
|
||||
if (type) where.type = type
|
||||
if (status) where.status = status
|
||||
const total = await (prisma as any).workProcess.count({ where })
|
||||
const items = await (prisma as any).workProcess.findMany({
|
||||
where,
|
||||
include: { employee: { select: { id: true, name: true, department: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (Number(page) - 1) * Number(pageSize),
|
||||
take: Number(pageSize),
|
||||
})
|
||||
res.json({ success: true, data: { items, total, page: Number(page), pageSize: Number(pageSize) } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 详情
|
||||
router.get('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const process = await (prisma as any).workProcess.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
include: { employee: { select: { id: true, name: true, department: true, status: true } } },
|
||||
})
|
||||
if (!process) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } })
|
||||
}
|
||||
res.json({ success: true, data: process })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 更新草稿
|
||||
router.patch('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const existing = await (prisma as any).workProcess.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!existing) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } })
|
||||
}
|
||||
if (existing.status !== 'DRAFT') {
|
||||
return res.status(400).json({ success: false, error: { code: 'NOT_DRAFT', message: '仅草稿状态可编辑' } })
|
||||
}
|
||||
const { title, employeeId, formData, remark } = req.body
|
||||
const updated = await (prisma as any).workProcess.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
...(title !== undefined && { title }),
|
||||
...(employeeId !== undefined && { employeeId: employeeId || null }),
|
||||
...(formData !== undefined && { formData }),
|
||||
...(remark !== undefined && { remark }),
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 提交办理
|
||||
router.post('/:id/submit', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const process = await (prisma as any).workProcess.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!process) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } })
|
||||
}
|
||||
if (process.status !== 'DRAFT') {
|
||||
return res.status(400).json({ success: false, error: { code: 'NOT_DRAFT', message: '仅草稿状态可提交' } })
|
||||
}
|
||||
// 执行业务联动
|
||||
let execResult: any = {}
|
||||
try {
|
||||
execResult = await executeWorkProcess(process.id, process.type, process.formData, req.user!.orgId, req.user!.id)
|
||||
} catch (execErr: any) {
|
||||
return res.status(400).json({ success: false, error: { code: 'EXEC_FAILED', message: `执行失败:${execErr?.message || '未知错误'}` } })
|
||||
}
|
||||
// 生成文书
|
||||
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId } })
|
||||
const doc = generateDocument(process.type, process.formData, org?.name || '')
|
||||
const documents = doc.content ? [doc] : []
|
||||
const updated = await (prisma as any).workProcess.update({
|
||||
where: { id: process.id },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
documents: documents.length > 0 ? documents : null,
|
||||
...(execResult.employeeId && !process.employeeId && { employeeId: execResult.employeeId }),
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 审批通过
|
||||
router.post('/:id/approve', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const process = await (prisma as any).workProcess.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!process) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } })
|
||||
}
|
||||
if (process.status !== 'PENDING_APPROVAL') {
|
||||
return res.status(400).json({ success: false, error: { code: 'NOT_PENDING', message: '仅待审批状态可审批' } })
|
||||
}
|
||||
let execResult: any = {}
|
||||
try {
|
||||
execResult = await executeWorkProcess(process.id, process.type, process.formData, req.user!.orgId, req.user!.id)
|
||||
} catch (execErr: any) {
|
||||
return res.status(400).json({ success: false, error: { code: 'EXEC_FAILED', message: `执行失败:${execErr?.message || '未知错误'}` } })
|
||||
}
|
||||
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId } })
|
||||
const doc = generateDocument(process.type, process.formData, org?.name || '')
|
||||
const documents = doc.content ? [doc] : []
|
||||
const updated = await (prisma as any).workProcess.update({
|
||||
where: { id: process.id },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
approverId: req.user!.id,
|
||||
approvedAt: new Date(),
|
||||
documents: documents.length > 0 ? documents : null,
|
||||
...(execResult.employeeId && !process.employeeId && { employeeId: execResult.employeeId }),
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 驳回
|
||||
router.post('/:id/reject', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const process = await (prisma as any).workProcess.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!process) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } })
|
||||
}
|
||||
if (process.status !== 'PENDING_APPROVAL') {
|
||||
return res.status(400).json({ success: false, error: { code: 'NOT_PENDING', message: '仅待审批状态可驳回' } })
|
||||
}
|
||||
const updated = await (prisma as any).workProcess.update({
|
||||
where: { id: process.id },
|
||||
data: {
|
||||
status: 'REJECTED',
|
||||
approverId: req.user!.id,
|
||||
approvedAt: new Date(),
|
||||
remark: req.body.reason || '驳回',
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 撤销
|
||||
router.post('/:id/cancel', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const process = await (prisma as any).workProcess.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!process) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } })
|
||||
}
|
||||
if (['COMPLETED', 'CANCELLED'].includes(process.status)) {
|
||||
return res.status(400).json({ success: false, error: { code: 'INVALID_STATUS', message: '已完成或已撤销的记录不可撤销' } })
|
||||
}
|
||||
const updated = await (prisma as any).workProcess.update({
|
||||
where: { id: process.id },
|
||||
data: { status: 'CANCELLED' },
|
||||
})
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 删除草稿
|
||||
router.delete('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const process = await (prisma as any).workProcess.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!process) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } })
|
||||
}
|
||||
if (process.status !== 'DRAFT') {
|
||||
return res.status(400).json({ success: false, error: { code: 'NOT_DRAFT', message: '仅草稿状态可删除' } })
|
||||
}
|
||||
await (prisma as any).workProcess.delete({ where: { id: process.id } })
|
||||
res.json({ success: true, data: { message: '已删除' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 预览文书
|
||||
router.get('/:id/preview', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const process = await (prisma as any).workProcess.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!process) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } })
|
||||
}
|
||||
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId } })
|
||||
const doc = generateDocument(process.type, process.formData, org?.name || '')
|
||||
res.json({ success: true, data: doc })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取流程类型列表
|
||||
router.get('/meta/types', authMiddleware, (_req: AuthRequest, res: Response) => {
|
||||
res.json({ success: true, data: PROCESS_TYPES })
|
||||
})
|
||||
|
||||
// 获取状态列表
|
||||
router.get('/meta/statuses', authMiddleware, (_req: AuthRequest, res: Response) => {
|
||||
res.json({ success: true, data: PROCESS_STATUS })
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,286 @@
|
||||
import prisma from '../lib/prisma'
|
||||
import { encrypt } from '../lib/crypto'
|
||||
import crypto from 'crypto'
|
||||
|
||||
// 13类流程定义
|
||||
export const PROCESS_TYPES: Record<string, { label: string; description: string; icon: string }> = {
|
||||
HIRE: { label: '员工录用', description: '录用新员工并起草劳动合同', icon: 'user-plus' },
|
||||
ONBOARD: { label: '员工入职', description: '办理员工入职手续', icon: 'log-in' },
|
||||
CUSTOM_CONTRACT: { label: '自定义合同签署', description: '自定义合同内容并签署', icon: 'file-signature' },
|
||||
INFO_SUBMIT: { label: '员工信息提交', description: '提交员工信息变更', icon: 'edit' },
|
||||
CONFIRM: { label: '员工转正', description: '试用期员工转正', icon: 'check-circle' },
|
||||
CHANGE: { label: '合同变更', description: '变更合同内容', icon: 'refresh-cw' },
|
||||
RENEW: { label: '合同续签', description: '到期合同续签', icon: 'repeat' },
|
||||
SUSPEND: { label: '合同中止', description: '中止履行合同', icon: 'pause' },
|
||||
INCOME_CERT: { label: '开具收入证明', description: '为员工开具收入证明', icon: 'file-text' },
|
||||
TERMINATE: { label: '合同终止', description: '合同到期终止', icon: 'x-circle' },
|
||||
RESCIND: { label: '合同解除', description: '协商或单方解除合同', icon: 'user-x' },
|
||||
LEAVING_CERT: { label: '开具离职证明', description: '为离职员工开具证明', icon: 'file-minus' },
|
||||
FLEXIBLE: { label: '灵活用工', description: '灵活用工协议签署', icon: 'briefcase' },
|
||||
}
|
||||
|
||||
export const PROCESS_STATUS: Record<string, { label: string; color: string }> = {
|
||||
DRAFT: { label: '草稿', color: 'bg-gray-100 text-gray-600' },
|
||||
PENDING_APPROVAL: { label: '待审批', color: 'bg-amber-50 text-amber-700' },
|
||||
APPROVED: { label: '已审批', color: 'bg-blue-50 text-blue-700' },
|
||||
REJECTED: { label: '已驳回', color: 'bg-red-50 text-red-700' },
|
||||
EXECUTING: { label: '执行中', color: 'bg-purple-50 text-purple-700' },
|
||||
COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe' },
|
||||
CANCELLED: { label: '已撤销', color: 'bg-gray-100 text-gray-400' },
|
||||
}
|
||||
|
||||
// 提交后业务联动
|
||||
export async function executeWorkProcess(processId: string, type: string, formData: any, orgId: string, userId: string) {
|
||||
switch (type) {
|
||||
case 'HIRE': {
|
||||
// 创建员工 + 合同
|
||||
const { name, department, hireDate, monthlySalary, phone, idCardNumber, gender, contractStartDate, contractEndDate, contractType = 'FIXED' } = formData
|
||||
const idCardHash = idCardNumber ? crypto.createHash('sha256').update(idCardNumber).digest('hex') : null
|
||||
const employee = await prisma.employee.create({
|
||||
data: {
|
||||
orgId,
|
||||
name,
|
||||
department: department || '未分配',
|
||||
hireDate: new Date(hireDate),
|
||||
monthlySalary: encrypt(String(monthlySalary || 0)),
|
||||
phone: phone || null,
|
||||
idCardNumber: idCardNumber ? encrypt(idCardNumber) : null,
|
||||
idCardHash,
|
||||
gender: gender || null,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
if (contractStartDate) {
|
||||
await prisma.laborContract.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: employee.id,
|
||||
startDate: new Date(contractStartDate),
|
||||
endDate: contractEndDate ? new Date(contractEndDate) : null,
|
||||
contractType: contractType as any,
|
||||
signMethod: 'PAPER',
|
||||
contractYears: 3,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
}
|
||||
return { employeeId: employee.id }
|
||||
}
|
||||
case 'ONBOARD': {
|
||||
const { employeeId, hireDate } = formData
|
||||
if (employeeId) {
|
||||
await prisma.employee.update({
|
||||
where: { id: employeeId },
|
||||
data: { hireDate: new Date(hireDate), status: 'ACTIVE' },
|
||||
})
|
||||
}
|
||||
return { employeeId }
|
||||
}
|
||||
case 'CONFIRM': {
|
||||
const { employeeId, confirmDate, regularSalary } = formData
|
||||
if (employeeId) {
|
||||
if (regularSalary) {
|
||||
await prisma.employee.update({
|
||||
where: { id: employeeId },
|
||||
data: { monthlySalary: encrypt(String(regularSalary)) },
|
||||
})
|
||||
}
|
||||
}
|
||||
return { employeeId }
|
||||
}
|
||||
case 'RENEW': {
|
||||
const { employeeId, oldContractId, newStartDate, newEndDate, newSalary, contractType = 'FIXED', contractYears = 3 } = formData
|
||||
if (oldContractId) {
|
||||
const oldEndDate = new Date(newStartDate)
|
||||
oldEndDate.setDate(oldEndDate.getDate() - 1)
|
||||
await prisma.laborContract.update({
|
||||
where: { id: oldContractId },
|
||||
data: { endDate: oldEndDate },
|
||||
})
|
||||
}
|
||||
if (employeeId) {
|
||||
const oldContract = oldContractId ? await prisma.laborContract.findUnique({ where: { id: oldContractId } }) : null
|
||||
const contract = await prisma.laborContract.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId,
|
||||
signDate: new Date(),
|
||||
startDate: new Date(newStartDate),
|
||||
endDate: newEndDate ? new Date(newEndDate) : null,
|
||||
contractType: contractType as any,
|
||||
signMethod: 'PAPER',
|
||||
contractYears: Number(contractYears) || 3,
|
||||
renewalCount: (oldContract?.renewalCount || 0) + 1,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
if (newSalary) {
|
||||
await prisma.employee.update({
|
||||
where: { id: employeeId },
|
||||
data: { monthlySalary: encrypt(String(newSalary)) },
|
||||
})
|
||||
}
|
||||
return { employeeId, newContractId: contract.id }
|
||||
}
|
||||
return { employeeId }
|
||||
}
|
||||
case 'TERMINATE': {
|
||||
const { employeeId, contractId, terminateDate } = formData
|
||||
if (contractId) {
|
||||
await prisma.laborContract.update({
|
||||
where: { id: contractId },
|
||||
data: { endDate: new Date(terminateDate) },
|
||||
})
|
||||
}
|
||||
if (employeeId) {
|
||||
await prisma.employee.update({
|
||||
where: { id: employeeId },
|
||||
data: { status: 'RESIGNED' },
|
||||
})
|
||||
}
|
||||
return { employeeId }
|
||||
}
|
||||
case 'RESCIND': {
|
||||
const { employeeId, contractId, rescindDate } = formData
|
||||
if (contractId) {
|
||||
await prisma.laborContract.update({
|
||||
where: { id: contractId },
|
||||
data: { endDate: new Date(rescindDate) },
|
||||
})
|
||||
}
|
||||
if (employeeId) {
|
||||
await prisma.employee.update({
|
||||
where: { id: employeeId },
|
||||
data: { status: 'RESIGNED' },
|
||||
})
|
||||
}
|
||||
return { employeeId }
|
||||
}
|
||||
case 'CHANGE': {
|
||||
const { contractId, newEndDate } = formData
|
||||
if (contractId && newEndDate) {
|
||||
await prisma.laborContract.update({
|
||||
where: { id: contractId },
|
||||
data: { endDate: new Date(newEndDate) },
|
||||
})
|
||||
}
|
||||
return { contractId }
|
||||
}
|
||||
case 'SUSPEND': {
|
||||
const { contractId, suspendDate } = formData
|
||||
if (contractId && suspendDate) {
|
||||
await prisma.laborContract.update({
|
||||
where: { id: contractId },
|
||||
data: { endDate: new Date(suspendDate) },
|
||||
})
|
||||
}
|
||||
return { contractId }
|
||||
}
|
||||
case 'CUSTOM_CONTRACT': {
|
||||
const { employeeId, contractStartDate, contractEndDate, contractType = 'FIXED', signMethod = 'PAPER', contractYears = 3 } = formData
|
||||
if (employeeId) {
|
||||
const contract = await prisma.laborContract.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId,
|
||||
signDate: new Date(),
|
||||
startDate: new Date(contractStartDate),
|
||||
endDate: contractEndDate ? new Date(contractEndDate) : null,
|
||||
contractType: contractType as any,
|
||||
signMethod: signMethod as any,
|
||||
contractYears: Number(contractYears) || 3,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
return { employeeId, newContractId: contract.id }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
case 'FLEXIBLE': {
|
||||
const { name, phone, idCardNumber, department, agreementStartDate, agreementEndDate, payMethod } = formData
|
||||
const idCardHash = idCardNumber ? crypto.createHash('sha256').update(idCardNumber).digest('hex') : null
|
||||
const employee = await prisma.employee.create({
|
||||
data: {
|
||||
orgId,
|
||||
name,
|
||||
department: department || '灵活用工',
|
||||
hireDate: new Date(agreementStartDate),
|
||||
monthlySalary: encrypt('0'),
|
||||
phone: phone || null,
|
||||
idCardNumber: idCardNumber ? encrypt(idCardNumber) : null,
|
||||
idCardHash,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
const contract = await prisma.laborContract.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: employee.id,
|
||||
signDate: new Date(),
|
||||
startDate: new Date(agreementStartDate),
|
||||
endDate: agreementEndDate ? new Date(agreementEndDate) : null,
|
||||
contractType: 'LABOR',
|
||||
signMethod: 'PAPER',
|
||||
contractYears: 1,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
return { employeeId: employee.id, newContractId: contract.id }
|
||||
}
|
||||
case 'INFO_SUBMIT': {
|
||||
const { employeeId, ...updateFields } = formData
|
||||
if (employeeId) {
|
||||
const allowedFields: Record<string, any> = {}
|
||||
if (updateFields.department) allowedFields.department = updateFields.department
|
||||
if (updateFields.phone) allowedFields.phone = updateFields.phone
|
||||
if (updateFields.address) allowedFields.address = updateFields.address
|
||||
if (updateFields.emergencyContact) allowedFields.emergencyContact = updateFields.emergencyContact
|
||||
if (updateFields.emergencyPhone) allowedFields.emergencyPhone = updateFields.emergencyPhone
|
||||
if (updateFields.bankAccount) allowedFields.bankAccount = encrypt(updateFields.bankAccount)
|
||||
if (updateFields.bankName) allowedFields.bankName = updateFields.bankName
|
||||
if (Object.keys(allowedFields).length > 0) {
|
||||
await prisma.employee.update({ where: { id: employeeId }, data: allowedFields })
|
||||
}
|
||||
}
|
||||
return { employeeId }
|
||||
}
|
||||
case 'INCOME_CERT':
|
||||
case 'LEAVING_CERT': {
|
||||
// 这两类只生成文书,不改变业务数据
|
||||
return {}
|
||||
}
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
// 生成文书预览
|
||||
export function generateDocument(type: string, formData: any, orgName: string): { name: string; content: string } {
|
||||
const templates: Record<string, (data: any, org: string) => string> = {
|
||||
INCOME_CERT: (data, org) => `收入证明
|
||||
|
||||
兹证明 ${data.employeeName || '___'}(身份证号:${data.idCardNumber || '___'})系我单位员工,自 ${data.hireDate || '___'} 起在我单位工作,现任 ${data.position || '___'} 职务。
|
||||
|
||||
该员工近一年平均月收入为人民币 ${data.monthlyIncome || '___'} 元(税前)。
|
||||
|
||||
本证明仅用于 ${data.purpose || '___'},不作其他用途。
|
||||
|
||||
特此证明。
|
||||
|
||||
${org}
|
||||
${new Date().toLocaleDateString('zh-CN')}`,
|
||||
LEAVING_CERT: (data, org) => `离职证明
|
||||
|
||||
兹证明 ${data.employeeName || '___'}(身份证号:${data.idCardNumber || '___'})自 ${data.hireDate || '___'} 至 ${data.leaveDate || '___'} 在我单位工作,最后职务为 ${data.position || '___'}。
|
||||
|
||||
该员工已于 ${data.leaveDate || '___'} 与我单位解除劳动关系,双方已办妥交接手续。
|
||||
|
||||
特此证明。
|
||||
|
||||
${org}
|
||||
${new Date().toLocaleDateString('zh-CN')}`,
|
||||
}
|
||||
const generator = templates[type]
|
||||
if (!generator) return { name: '', content: '' }
|
||||
return { name: `${PROCESS_TYPES[type]?.label || '文书'}.doc`, content: generator(formData, orgName) }
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
# 企业用工专家 — 功能补齐方案
|
||||
|
||||
> 日期:2026-07-30
|
||||
> 依据:白话用工侠完整运行测试报告对比分析
|
||||
> 优先级:🔴 高 / 🟡 中
|
||||
|
||||
---
|
||||
|
||||
## 一、用工办理工作流系统(13类流程)🔴
|
||||
|
||||
### 1.1 目标
|
||||
|
||||
实现统一的用工办理工作流系统,覆盖员工从录用到离职的全生命周期办理事项。
|
||||
|
||||
### 1.2 13类流程定义
|
||||
|
||||
| 编号 | 流程名称 | 核心表单字段 | 关联模块 |
|
||||
|------|---------|------------|---------|
|
||||
| 1 | 员工录用 | 人员选择、入职时间、公司地址、部门、岗位、直属上级、联系方式、试用期薪酬、转正薪酬、携带材料 | 员工创建+合同起草 |
|
||||
| 2 | 员工入职 | 入职日期、岗位确认、合同签署方式、材料提交清单 | 员工状态→ACTIVE |
|
||||
| 3 | 自定义合同签署 | 合同模板选择、变量填充、签署方、期限 | 合同管理 |
|
||||
| 4 | 员工信息提交 | 信息变更字段、证明材料 | 员工档案更新 |
|
||||
| 5 | 员工转正 | 转正日期、转正薪资、考核结果 | 员工状态+薪资变更 |
|
||||
| 6 | 合同变更 | 变更类型、变更内容、生效日期 | 合同管理 |
|
||||
| 7 | 合同续签 | 续签次数、新期限、新薪资 | 合同管理 |
|
||||
| 8 | 合同中止 | 中止原因、中止期限、预计恢复日期 | 合同状态 |
|
||||
| 9 | 开具收入证明 | 用途、收入期间、接收方 | 文本模板渲染 |
|
||||
| 10 | 合同终止 | 终止原因、终止日期、经济补偿 | 合同状态+离职 |
|
||||
| 11 | 合同解除 | 解除原因、解除方式、协商/单方 | 已有 Termination 模块复用 |
|
||||
| 12 | 开具离职证明 | 离职日期、离职原因、接收方 | 文本模板渲染 |
|
||||
| 13 | 灵活用工 | 人员信息、用工类型、协议期限、计酬方式 | 合同管理(LABOR) |
|
||||
|
||||
### 1.3 数据模型设计
|
||||
|
||||
```prisma
|
||||
// 用工办理流程
|
||||
model WorkProcess {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
type String // HIRE/ONBOARD/CUSTOM_CONTRACT/INFO_SUBMIT/CONFIRM/CHANGE/RENEW/SUSPEND/INCOME_CERT/TERMINATE/RESCIND/LEAVING_CERT/FLEXIBLE
|
||||
title String
|
||||
employeeId String?
|
||||
employee Employee? @relation(fields: [employeeId], references: [id])
|
||||
status String @default("DRAFT") // DRAFT/PENDING_APPROVAL/APPROVED/REJECTED/EXECUTING/COMPLETED/CANCELLED
|
||||
formData Json // 表单数据 JSON
|
||||
documents Json? // 生成的文书列表 [{name, content, type}]
|
||||
approverId String?
|
||||
approvedAt DateTime?
|
||||
remark String?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([orgId, status])
|
||||
@@index([orgId, type])
|
||||
@@index([orgId, createdBy])
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 前端架构
|
||||
|
||||
**新增页面**:`frontend/src/pages/WorkProcess.tsx`
|
||||
|
||||
```
|
||||
页面结构:
|
||||
├── 发起办理(13类流程卡片选择)
|
||||
├── 办理记录(列表+筛选:类型/状态/日期)
|
||||
├── 办理草稿(仅 DRAFT 状态)
|
||||
└── 流程详情(步骤条+表单+预览+操作)
|
||||
```
|
||||
|
||||
**流程详情组件**(通用 Wizard):
|
||||
- 步骤条:填写信息 → 预览文书 → 存草稿/提交
|
||||
- 表单根据 `type` 动态渲染字段
|
||||
- 预览:调用文本模板渲染接口生成文书
|
||||
- 提交后根据流程类型执行对应业务逻辑
|
||||
|
||||
**复用已有模块**:
|
||||
- 合同解除(#11)→ 复用 `Termination.tsx` 的 Wizard 逻辑
|
||||
- 合同续签(#7)→ 复用 `Contracts.tsx` 的合同管理逻辑
|
||||
- 收入证明(#9)/ 离职证明(#12)→ 复用 `Templates.tsx` 的模板渲染
|
||||
- 员工录用(#1)→ 复用 `roster/modals.tsx` 的添加员工逻辑
|
||||
|
||||
### 1.5 后端接口
|
||||
|
||||
```
|
||||
POST /api/v1/work-processes # 创建办理(含草稿)
|
||||
GET /api/v1/work-processes # 列表查询(支持type/status筛选)
|
||||
GET /api/v1/work-processes/:id # 详情
|
||||
PATCH /api/v1/work-processes/:id # 更新草稿
|
||||
POST /api/v1/work-processes/:id/submit # 提交办理
|
||||
POST /api/v1/work-processes/:id/approve # 审批通过
|
||||
POST /api/v1/work-processes/:id/reject # 驳回
|
||||
POST /api/v1/work-processes/:id/cancel # 撤销
|
||||
DELETE /api/v1/work-processes/:id # 删除草稿
|
||||
GET /api/v1/work-processes/:id/preview # 预览生成的文书
|
||||
```
|
||||
|
||||
### 1.6 提交后业务联动
|
||||
|
||||
| 流程类型 | 提交后执行 |
|
||||
|---------|-----------|
|
||||
| HIRE | 创建员工记录 + 创建劳动合同 |
|
||||
| ONBOARD | 更新员工状态为 ACTIVE + 记录入职日期 |
|
||||
| CUSTOM_CONTRACT | 创建劳动合同 |
|
||||
| INFO_SUBMIT | 更新员工档案字段 |
|
||||
| CONFIRM | 更新试用期结束 + 调整薪资 |
|
||||
| CHANGE | 更新合同字段 + 记录变更历史 |
|
||||
| RENEW | 关闭旧合同 + 创建新合同 |
|
||||
| SUSPEND | 合同状态改为 SUSPENDED |
|
||||
| INCOME_CERT | 生成收入证明文书(不改变业务数据) |
|
||||
| TERMINATE | 合同状态改为 TERMINATED + 触发离职流程 |
|
||||
| RESCIND | 调用已有 Termination 逻辑 |
|
||||
| LEAVING_CERT | 生成离职证明文书 |
|
||||
| FLEXIBLE | 创建劳务协议合同 |
|
||||
|
||||
### 1.7 实现优先级
|
||||
|
||||
1. **Phase 1**:数据模型 + 基础 CRUD + 列表/草稿页面
|
||||
2. **Phase 2**:员工录用、员工入职、合同续签、合同终止(4个高频流程)
|
||||
3. **Phase 3**:剩余9类流程 + 文书预览生成
|
||||
4. **Phase 4**:审批流程 + 办理记录导出
|
||||
|
||||
---
|
||||
|
||||
## 二、企业自建文本库 🟡
|
||||
|
||||
### 2.1 目标
|
||||
|
||||
允许企业创建、管理和复用自己的文本模板(合同、通知、协议等),与系统预置模板库并存。
|
||||
|
||||
### 2.2 数据模型设计
|
||||
|
||||
```prisma
|
||||
// 企业自建文本模板
|
||||
model EnterpriseTemplate {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
category String // CONTRACT/RULES/NOTICE/AGREEMENT/OTHER
|
||||
description String?
|
||||
content String @db.Text
|
||||
variables String[] // 变量名列表
|
||||
status String @default("ACTIVE") // ACTIVE/ARCHIVED
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([orgId, category])
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 前端方案
|
||||
|
||||
**改造页面**:`frontend/src/pages/Templates.tsx`
|
||||
|
||||
在现有模板库页面顶部新增 Tab 切换:
|
||||
- **系统模板库**(现有功能不变)
|
||||
- **企业文本库**(新增)
|
||||
|
||||
企业文本库 Tab 内容:
|
||||
```
|
||||
├── 查询栏(名称搜索 + 分类筛选)
|
||||
├── 新建/编辑弹窗(名称、分类、变量配置、内容编辑)
|
||||
├── 模板卡片列表(复用现有卡片样式)
|
||||
├── 详情弹窗(变量填写 → 渲染 → 下载Word/复制)
|
||||
└── 操作:编辑、复制、删除、归档
|
||||
```
|
||||
|
||||
### 2.4 后端接口
|
||||
|
||||
```
|
||||
# 现有模板路由:/api/v1/templates(系统预置,只读)
|
||||
# 新增企业模板路由:/api/v1/enterprise-templates
|
||||
|
||||
GET /api/v1/enterprise-templates # 列表(支持category/search)
|
||||
POST /api/v1/enterprise-templates # 新建
|
||||
GET /api/v1/enterprise-templates/:id # 详情
|
||||
PUT /api/v1/enterprise-templates/:id # 更新
|
||||
DELETE /api/v1/enterprise-templates/:id # 删除
|
||||
POST /api/v1/enterprise-templates/:id/render # 渲染(复用现有 renderTemplate 逻辑)
|
||||
GET /api/v1/enterprise-templates/:id/download # 下载Word
|
||||
```
|
||||
|
||||
### 2.5 实现要点
|
||||
|
||||
- 变量提取:编辑内容时自动扫描 `{{变量名}}` 提取变量列表
|
||||
- 渲染逻辑:复用 `template.service.ts` 的 `renderTemplate` 函数
|
||||
- 内容编辑器:使用 textarea + 变量插入按钮(点击插入 `{{变量名}}`)
|
||||
- 权限:ADMIN/HR 可增删改,VIEWER 只读
|
||||
|
||||
---
|
||||
|
||||
## 三、考勤/工资条发布流程 🟡
|
||||
|
||||
### 3.1 考勤发布
|
||||
|
||||
#### 目标
|
||||
HR 在考勤管理页面将月度考勤数据"发布"给员工,员工在员工端查看并确认。
|
||||
|
||||
#### 数据模型
|
||||
|
||||
```prisma
|
||||
// 考勤发布记录
|
||||
model AttendancePublish {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
month String // 2026-07
|
||||
title String // 如"2026年7月考勤表"
|
||||
status String @default("PUBLISHED") // PUBLISHED/CANCELLED
|
||||
publishDate DateTime @default(now())
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([orgId, month])
|
||||
@@index([orgId, month])
|
||||
}
|
||||
```
|
||||
|
||||
#### 前端改造:`Attendance.tsx`
|
||||
|
||||
在「考勤确认」Tab 工具栏新增:
|
||||
- **「发布考勤表」按钮**:弹窗确认 → 选择月份 → 发布
|
||||
- **「发布记录」入口**:查看历史发布记录,可取消发布
|
||||
|
||||
#### 后端接口
|
||||
|
||||
```
|
||||
POST /api/v1/attendance/publish # 发布考勤表
|
||||
GET /api/v1/attendance/publish-records # 发布记录列表
|
||||
POST /api/v1/attendance/publish/:id/cancel # 取消发布
|
||||
```
|
||||
|
||||
#### 员工端:`portal/` 新增考勤查看页面
|
||||
|
||||
```
|
||||
GET /api/v1/portal/attendance?month=2026-07 # 员工查看自己的月度考勤
|
||||
```
|
||||
|
||||
### 3.2 工资条发布
|
||||
|
||||
#### 目标
|
||||
HR 在薪税管理中将批次工资条"发布"给员工,员工在员工端查看并确认。支持定时发送。
|
||||
|
||||
#### 数据模型
|
||||
|
||||
复用已有 `Payslip` 模型,新增发布状态字段:
|
||||
|
||||
```prisma
|
||||
// 在 Payslip 模型新增字段
|
||||
model Payslip {
|
||||
// ...已有字段
|
||||
publishStatus String? // UNPUBLISHED/PUBLISHED/SCHEDULED
|
||||
publishedAt DateTime?
|
||||
scheduledAt DateTime? // 定时发送时间
|
||||
confirmedAt DateTime? // 员工确认时间(已有)
|
||||
}
|
||||
```
|
||||
|
||||
#### 前端改造:`Money.tsx`
|
||||
|
||||
在批次详情页新增:
|
||||
- **「发布工资条」按钮**:将批次内所有工资条标记为 PUBLISHED
|
||||
- **「定时发送」选项**:选择发送时间,到点自动发布
|
||||
- **「定时发送记录」入口**:查看定时发送列表,可取消
|
||||
|
||||
#### 后端接口
|
||||
|
||||
```
|
||||
POST /api/v1/payroll/batches/:batchId/publish # 发布工资条
|
||||
POST /api/v1/payroll/batches/:batchId/schedule # 定时发送
|
||||
GET /api/v1/payroll/schedule-records # 定时发送记录
|
||||
POST /api/v1/payroll/schedule/:id/cancel # 取消定时发送
|
||||
```
|
||||
|
||||
#### 员工端:已有 `portal/Payslip.tsx`
|
||||
|
||||
- 发布前:员工端不显示该月工资条
|
||||
- 发布后:员工端显示工资条,可查看和确认
|
||||
- 定时发送:到点后状态从 SCHEDULED → PUBLISHED
|
||||
|
||||
#### 定时发送实现
|
||||
|
||||
- 使用 `node-cron` 或现有定时任务机制
|
||||
- 每分钟检查 `scheduledAt <= now && publishStatus = SCHEDULED` 的记录
|
||||
- 自动更新为 PUBLISHED 并发送通知
|
||||
|
||||
---
|
||||
|
||||
## 四、AI文件审查上传功能 🔴
|
||||
|
||||
### 4.1 目标
|
||||
|
||||
在现有合同审查功能基础上,支持上传 doc/docx 文件,自动提取文本后进行 AI 审查。
|
||||
|
||||
### 4.2 前端改造:`AIAssistant.tsx` ReviewTab
|
||||
|
||||
在现有粘贴文本输入框上方新增文件上传区域:
|
||||
|
||||
```
|
||||
合同审查 Tab 改造:
|
||||
├── 文件上传区(新增)
|
||||
│ ├── 文书类型选择(劳动合同/协商解除/劳务协议/实习协议/保密协议)
|
||||
│ ├── 拖拽或点击上传 .doc/.docx 文件
|
||||
│ ├── 文件大小限制:100MB
|
||||
│ └── 上传后自动提取文本并填入下方输入框
|
||||
├── 文本输入框(现有)
|
||||
├── 开始审查按钮(现有)
|
||||
└── 审查结果展示(现有)
|
||||
```
|
||||
|
||||
### 4.3 后端改造
|
||||
|
||||
#### 文件上传接口
|
||||
|
||||
```
|
||||
POST /api/v1/ai/review/upload
|
||||
- multipart/form-data
|
||||
- 接收 doc/docx 文件
|
||||
- 提取纯文本
|
||||
- 返回 { text: "提取的文本内容" }
|
||||
```
|
||||
|
||||
#### 文本提取方案
|
||||
|
||||
使用 `mammoth` 库提取 .docx 文本:
|
||||
|
||||
```typescript
|
||||
import mammoth from 'mammoth'
|
||||
|
||||
// .docx 文件提取
|
||||
const result = await mammoth.extractRawText({ buffer: req.file.buffer })
|
||||
const text = result.value
|
||||
|
||||
// .doc 文件(旧格式)
|
||||
// 方案1:使用 antiword 命令行工具
|
||||
// 方案2:提示用户转换为 .docx
|
||||
// 推荐:仅支持 .docx,.doc 提示转换
|
||||
```
|
||||
|
||||
#### 完整审查流程
|
||||
|
||||
```typescript
|
||||
// 1. 上传文件 → 提取文本
|
||||
POST /ai/review/upload → { text }
|
||||
|
||||
// 2. 前端将文本填入输入框(用户可编辑)
|
||||
|
||||
// 3. 点击审查 → 调用现有 /ai/review 接口
|
||||
POST /ai/review { contractText } → 审查结果
|
||||
```
|
||||
|
||||
### 4.4 依赖安装
|
||||
|
||||
```bash
|
||||
cd backend && npm install mammoth
|
||||
```
|
||||
|
||||
### 4.5 安全考虑
|
||||
|
||||
- 文件大小限制:100MB(`multer` limits)
|
||||
- 文件类型校验:仅 `.doc` 和 `.docx`
|
||||
- 提取后不保存原文件,仅返回文本
|
||||
- 文本长度截断:超过 50000 字符时截断并提示
|
||||
|
||||
---
|
||||
|
||||
## 五、实施计划
|
||||
|
||||
### 5.1 优先级排序
|
||||
|
||||
| 优先级 | 功能 | 预估工作量 | 建议时间 |
|
||||
|--------|------|-----------|---------|
|
||||
| 🔴 高 | AI文件审查上传 | 1-2天 | 立即 |
|
||||
| 🔴 高 | 用工办理工作流 Phase 1-2 | 5-7天 | 本周 |
|
||||
| 🟡 中 | 企业自建文本库 | 2-3天 | 下周 |
|
||||
| 🟡 中 | 考勤发布 | 2天 | 下周 |
|
||||
| 🟡 中 | 工资条发布 | 2-3天 | 下周 |
|
||||
| 🔴 高 | 用工办理工作流 Phase 3-4 | 5-7天 | 第三周 |
|
||||
|
||||
### 5.2 总计
|
||||
|
||||
- **总工作量**:约 17-24 个工作日
|
||||
- **建议分3周完成**:
|
||||
- 第1周:AI文件审查 + 用工办理Phase 1-2
|
||||
- 第2周:企业文本库 + 考勤发布 + 工资条发布
|
||||
- 第3周:用工办理Phase 3-4 + 联调测试
|
||||
|
||||
### 5.3 数据库迁移
|
||||
|
||||
所有新增模型需要执行 `npx prisma db push` 同步到数据库。
|
||||
|
||||
---
|
||||
|
||||
## 六、风险与注意事项
|
||||
|
||||
1. **用工办理工作流**是最复杂的功能,建议先实现4个高频流程(录用/入职/续签/终止),验证架构后再扩展
|
||||
2. **企业文本库**的变量提取逻辑需与系统模板保持一致,复用 `renderTemplate` 函数
|
||||
3. **工资条发布**涉及薪资敏感数据,需确保只有发布后员工端才能看到
|
||||
4. **AI文件审查**的 .doc 旧格式支持有限,建议仅支持 .docx 并提示用户转换
|
||||
5. **定时发送**需要确保服务器进程持续运行(PM2 已有保障)
|
||||
@@ -0,0 +1,376 @@
|
||||
# 白话用工侠完整运行测试报告
|
||||
|
||||
## 1. 报告结论
|
||||
|
||||
- 测试时间:2026-07-30 08:56—09:12(Asia/Shanghai)
|
||||
- 测试地址:[https://console.bhygx.com/ent_app_dashboard](https://console.bhygx.com/ent_app_dashboard)
|
||||
- 测试企业:唐山菲斯克人力资源服务有限公司
|
||||
- 测试账号:`139****9106`(报告不记录密码)
|
||||
- 测试方式:使用真实浏览器登录,逐项进入菜单、列表、表单、筛选器、计算器、风险待办和视频播放页面,执行可逆操作并核对页面状态。
|
||||
- 总体结论:核心菜单和主要业务入口可以正常使用;员工、合同、模板、风险待办等数据可以加载。当前最需要优先处理的是 3 条严重超期合同待办、待办统计口径不一致、医疗期法律依据时效、背景调查额度异常显示,以及频繁出现的前端网络/脚本错误。
|
||||
|
||||
本次没有执行任何会正式改变业务数据的操作,包括:发布通知、发送 AI 问题、提交判赔、上传文件、发起背调、保存草稿、办理合同续签/终止、删除合同、提交民主程序、提交诊断问卷、导出人员或下载合同。
|
||||
|
||||
## 2. 核心业务数据概览
|
||||
|
||||
### 2.1 工作台合规数据
|
||||
|
||||
| 指标 | 当前值 |
|
||||
|---|---:|
|
||||
| 合规健康度 | 63 / 100,中等风险 |
|
||||
| 风险点 | 3 |
|
||||
| 首页待处理 | 4 项 |
|
||||
| 合同管理评分 | 90 |
|
||||
| 规章制度评分 | 60 |
|
||||
| 考勤工时评分 | 80 |
|
||||
| 员工档案评分 | 52 |
|
||||
| 社保公积金评分 | 55 |
|
||||
| 本年度价值贡献 | ¥0 |
|
||||
| 民主中 / 公示中 / 废弃制度 | 0 / 0 / 0 |
|
||||
| 用工体检 | 尚未提交问卷 |
|
||||
|
||||
### 2.2 人员与合同
|
||||
|
||||
| 项目 | 当前值 |
|
||||
|---|---:|
|
||||
| 花名册总人数 | 25 |
|
||||
| 在职 | 21 |
|
||||
| 正式员工 | 21 |
|
||||
| 试用期 | 0 |
|
||||
| 待入职 | 0 |
|
||||
| 离职 | 4 |
|
||||
| 其他人员 | 4 |
|
||||
| 试用期即将到期 | 0 |
|
||||
| 合同总数 | 70 |
|
||||
| 首页合同超期待办 | 3 条 |
|
||||
|
||||
3 条待办合同分别已经超期约 364、397、411 天。为保护个人信息,本报告不写入员工姓名、手机号和合同文件链接。
|
||||
|
||||
### 2.3 内容与业务记录
|
||||
|
||||
| 模块 | 当前数据 |
|
||||
|---|---|
|
||||
| 考勤管理 | 暂无发布记录 |
|
||||
| 工资条管理 | 暂无发布记录 |
|
||||
| 办理记录 | 暂无记录 |
|
||||
| 办理草稿 | 暂无草稿 |
|
||||
| 企业文本库 | 暂无企业自建文本 |
|
||||
| 用工文本模板库 | 87 个模板 |
|
||||
| 视频中心 | 20 个课程模块;列表第一页展示 12 个 |
|
||||
| 通知管理 | 暂无通知记录 |
|
||||
| 民主程序 | 暂无民主/公示记录 |
|
||||
|
||||
## 3. 完整菜单与功能测试结果
|
||||
|
||||
### 3.1 登录与全局框架
|
||||
|
||||
| 检查项 | 结果 | 说明 |
|
||||
|---|---|---|
|
||||
| 账号密码登录 | 通过 | 能从登录页正常进入企业工作台 |
|
||||
| 企业切换 | 通过 | 当前只显示 1 家企业,并提供“全部企业”入口 |
|
||||
| 左侧菜单 | 通过 | 菜单展开、收起和路由跳转正常 |
|
||||
| 顶部通知 | 可见 | 页面右上角有通知角标 |
|
||||
| 返回工作台 | 通过 | 测试结束后已恢复到工作台 |
|
||||
|
||||
### 3.2 工作台
|
||||
|
||||
已验证:
|
||||
|
||||
- 合规健康度及 5 个评分细项正常显示。
|
||||
- 高优先级风险预警会轮播显示不同员工的合同到期提醒。
|
||||
- 点击“立即处理”后,会在当前页面打开待办中心。
|
||||
- 待办中心包含“待办 / 已办”“全部 / 超期未处理 / 今日到期”筛选。
|
||||
- 进入具体合同待办后,可打开“发送续签通知”处理弹窗。
|
||||
- 弹窗支持:发送续签通知、已线下续签、终止合同、自定义签署。
|
||||
- 弹窗展示合同名称、起始日期、到期状态和合同 PDF 查看链接。
|
||||
- 本次只打开并关闭弹窗,没有选择续签次数,也没有点击提交。
|
||||
|
||||
### 3.3 白小侠 AI
|
||||
|
||||
#### 智能问答
|
||||
|
||||
- 页面正常加载,包含新对话、最近会话、转专家、附件上传和问题输入框。
|
||||
- 输入测试文字后,发送按钮会启用;清空文字后恢复禁用。
|
||||
- 未发送测试问题,避免生成外部消息或消耗服务额度。
|
||||
|
||||
#### 文件审查
|
||||
|
||||
- 页面正常加载,可选择文书类型。
|
||||
- 已验证类型选择和上传入口联动。
|
||||
- 当前可见类型包括:劳动合同、协商解除劳动合同协议、劳务协议、实习协议、保密协议。
|
||||
- 文件限制提示:`doc/docx`,100MB 以内。
|
||||
- 未上传真实文件,未创建审查任务。
|
||||
|
||||
#### 判赔预测器
|
||||
|
||||
- 页面正常加载,提供 12 类争议场景。
|
||||
- 可填写员工/工龄、事实、地区、月薪、制度公示和证据留痕等信息。
|
||||
- 页面自带演示数据,并明确提示结果仅供决策参考。
|
||||
- 未点击“预测仲裁结果”,避免调用正式预测或消耗额度。
|
||||
|
||||
### 3.4 员工管理
|
||||
|
||||
#### 花名册
|
||||
|
||||
- 共 25 人,第一页正常展示 10 人。
|
||||
- 姓名搜索可用,但输入后需要按 Enter 才会触发查询。
|
||||
- 使用已存在的姓名测试后,结果正确收敛为 1 条;清空并按 Enter 后恢复 25 条。
|
||||
- 支持部门筛选、添加人员、导出、人事管理、更多操作。
|
||||
- “添加人员”弹窗可正常打开,包含:
|
||||
- 添加已入职员工;
|
||||
- 添加待入职人员;
|
||||
- 在线创建;
|
||||
- 二维码邀请;
|
||||
- 批量导入。
|
||||
- 本次未新增、导入或导出人员。
|
||||
|
||||
#### 考勤管理
|
||||
|
||||
- 页面、姓名搜索、日期范围和“发布考勤表”入口正常。
|
||||
- 当前暂无考勤发布记录。
|
||||
- 页面说明仅支持平台标准模板。
|
||||
- 未上传或发布考勤表。
|
||||
|
||||
#### 工资条管理
|
||||
|
||||
- 页面、姓名搜索、日期范围、“定时发送记录”和“发布工资条”入口正常。
|
||||
- 当前暂无工资条发布记录。
|
||||
- 页面说明支持定时发送,且发送前可以取消。
|
||||
- 未上传或发布工资条。
|
||||
|
||||
### 3.5 用工办理
|
||||
|
||||
“发起办理”页面可正常进入,当前提供 13 类流程:
|
||||
|
||||
1. 员工录用
|
||||
2. 员工入职
|
||||
3. 自定义合同签署
|
||||
4. 员工信息提交
|
||||
5. 员工转正
|
||||
6. 合同变更
|
||||
7. 合同续签
|
||||
8. 合同中止
|
||||
9. 开具收入证明
|
||||
10. 合同终止
|
||||
11. 合同解除
|
||||
12. 开具离职证明
|
||||
13. 灵活用工
|
||||
|
||||
已实际打开“员工录用”表单,验证以下区域可以加载:人员选择、入职时间、公司地址、部门、岗位、直属上级、联系方式、试用期薪酬、转正薪酬、携带材料、预览、流程图、存草稿和发布按钮。
|
||||
|
||||
办理记录和办理草稿页面的查询、重置和列表结构正常,目前均无记录。本次未保存草稿、未预览生成文书、未发布录用通知。
|
||||
|
||||
### 3.6 合同管理
|
||||
|
||||
- 合同列表共 70 条,分页正常,第一页展示 10 条。
|
||||
- 支持合同类型、合同状态、创建日期查询及重置。
|
||||
- 列表支持查看、下载和删除。
|
||||
- 同时存在履约中、已到期和无固定期限合同。
|
||||
- 本次未查看合同正文、未下载、未删除。
|
||||
|
||||
### 3.7 用工风控
|
||||
|
||||
#### 背景调查
|
||||
|
||||
- 工作台显示背景调查入口、人员选择框、免责声明和“发起背调”按钮。
|
||||
- 当前页面显示“剩余:-1次”,但按钮仍处于可点击状态。
|
||||
- 未勾选免责声明、未选择人员、未发起调查。
|
||||
|
||||
#### 规章制度民主程序
|
||||
|
||||
- 页面说明、人员范围、文件选择、标题、截止日期、说明、发布按钮均正常。
|
||||
- 说明框包含系统预置的 148 字通知模板。
|
||||
- 当前无民主记录和公示记录。
|
||||
- 未选择文件、未发布民主程序。
|
||||
|
||||
#### 用工体检诊断
|
||||
|
||||
- 页面可正常进入,列出劳动合同、规章制度、社保、背景调查、工时加班、竞业限制等诊断维度。
|
||||
- 点击“开始诊断”会离开平台,跳转到腾讯问卷 `wj.qq.com`。
|
||||
- 外部问卷能够打开,首题正常显示。
|
||||
- 外部 URL 携带 `openid` 查询参数;本报告不记录其具体值。
|
||||
- 未回答或提交问卷,随后返回平台。
|
||||
|
||||
### 3.8 知识中心
|
||||
|
||||
#### 企业文本库
|
||||
|
||||
- 查询、重置、新建入口正常。
|
||||
- 使用不存在的名称查询后正确显示空结果,重置后输入框清空。
|
||||
- 当前没有企业自建文本。
|
||||
- 未新建或上传文本。
|
||||
|
||||
#### 用工文本模板库
|
||||
|
||||
- 共 87 个模板,包含劳动关系、社保公积金、并购、跨境外派等类别。
|
||||
- 查询和重置入口正常。
|
||||
- 未下载或使用模板发起流程。
|
||||
|
||||
#### 视频中心
|
||||
|
||||
- 课程搜索、课程类型筛选、分页和课程卡片正常。
|
||||
- 使用不存在的课程名搜索后结果清空;清除关键词并再次搜索后恢复 12 个课程卡片。
|
||||
- 第一页有 12 个课程,详情页目录总计 20 个模块。
|
||||
- 已打开第一门课程,视频播放器能够加载并开始播放;随后已暂停。
|
||||
- 未持续播放、未切换全屏或画中画。
|
||||
|
||||
### 3.9 通知管理
|
||||
|
||||
- 通知标题搜索、发布通知和列表结构正常。
|
||||
- 当前暂无通知记录。
|
||||
- 发布表单可以正常打开,包含标题、正文、目标人群和定时发布。
|
||||
- 目标人群显示“全体人员(21人)”,与花名册在职人数一致。
|
||||
- 本次点击取消退出,没有发布通知。
|
||||
|
||||
### 3.10 常用计算器
|
||||
|
||||
#### 五险一金计算器
|
||||
|
||||
已实际运行一次无副作用计算:
|
||||
|
||||
- 城市:唐山市
|
||||
- 社保基数:3920.55
|
||||
- 公积金基数:2200
|
||||
- 公积金比例:单位 5% + 个人 5%
|
||||
- 个人缴纳:570.16
|
||||
- 企业缴纳:1388.34
|
||||
- 合计:1958.50
|
||||
|
||||
计算明细能够正常展开,养老、医疗、大病、失业、工伤、长期护理和公积金项目均有结果。
|
||||
|
||||
#### 医疗期计算器
|
||||
|
||||
- 上海 / 全国(非上海)模式切换正常。
|
||||
- 上海模式包含入职日期、计算截止日期和累计病休天数。
|
||||
- 全国模式包含入职日期、累计工龄满十年日期、连续/非连续病休、病休起止日期。
|
||||
- 未填写真实员工日期,未生成正式计算结果。
|
||||
|
||||
## 4. 发现的问题与风险
|
||||
|
||||
### P0:业务合规风险——3 条合同严重超期
|
||||
|
||||
待办中心有 3 条合同到期任务,分别超期约 364、397、411 天。系统本身已经将其标记为高优先级风险,但仍长期未处理。
|
||||
|
||||
建议:立即由 HR/法务逐人核实实际劳动关系、线下续签材料、是否已离职、是否应补签或归档。尤其要确认是否已经形成无固定期限劳动合同、事实劳动关系或违法终止风险。
|
||||
|
||||
### P1:已离职人员仍出现在续签待办中
|
||||
|
||||
3 条合同到期待办中,至少有 1 人在花名册里的员工状态为“离职”,但仍持续出现在“发送续签通知”办理入口。
|
||||
|
||||
可能原因:离职流程未闭环、合同终止状态未同步、历史待办未归档,或风控规则没有排除离职人员。
|
||||
|
||||
建议:核对员工状态、合同状态和待办状态的同步规则;离职完成后应关闭不再适用的续签待办,或明确标记为“历史遗留待核验”。
|
||||
|
||||
### P1:首页待处理数量与待办列表不一致
|
||||
|
||||
- 首页显示:4 项待处理。
|
||||
- 点击“立即处理”后的“全部”待办列表:3 条记录,分页只有 1 页。
|
||||
|
||||
建议:确认首页是否混合统计了其他类型任务;如果是,应在待办中心展示分类和总数。如果不是,则修复统计缓存或任务状态同步。
|
||||
|
||||
### P1:医疗期计算器展示的上海法规有效期已经过期
|
||||
|
||||
页面仍显示相关规定“效期延长至 2025年6月30日”,而测试日期为 2026-07-30。
|
||||
|
||||
风险:医疗期属于高敏感劳动合规事项,过期的有效期说明会削弱计算结果可信度,并可能导致错误的人事决定。
|
||||
|
||||
建议:立即核对法规是否再次延长、被替代或废止;更新法律依据、有效期和最后校验日期,并在计算结果旁显示政策版本。
|
||||
|
||||
### P1:背景调查额度显示为负数
|
||||
|
||||
页面显示“剩余:-1次”,同时“发起背调”按钮仍可点击。
|
||||
|
||||
可能原因:后端使用 `-1` 表示不限次数,但前端直接展示了内部哨兵值;也可能是额度扣减异常。
|
||||
|
||||
建议:如果 `-1` 表示不限次数,前端应显示“无限次”;如果不是,应禁止继续发起并修复额度计算。
|
||||
|
||||
### P2:员工风险分布统计口径不清晰
|
||||
|
||||
花名册总人数为 25,但初始风险分布显示“高风险 0、中风险 0、健康 10”,三项合计只有 10,恰好等于当前页条数。按姓名筛选到 1 人后,“健康”也变为 1。
|
||||
|
||||
建议:明确该统计是“当前页”“当前筛选结果”还是“全体员工”。如果是当前页,应在标题中明确说明;更推荐默认展示全量/筛选结果总计,而不是只统计分页可见数据。
|
||||
|
||||
### P2:无固定期限合同在花名册中显示为超长剩余天数
|
||||
|
||||
合同管理中,部分记录明确标记为“无固定期限合同”,到期日使用 `2099-12-31`。花名册则显示为“剩余约 2.68 万天”。
|
||||
|
||||
建议:花名册应识别无固定期限合同,直接显示“无固定期限”,不要展示由占位日期计算出的剩余天数,以免误导业务人员和风险评分。
|
||||
|
||||
### P2:前端错误日志频繁
|
||||
|
||||
本次走查期间捕获到站点脚本产生的错误:
|
||||
|
||||
| 错误 | 次数 |
|
||||
|---|---:|
|
||||
| `Axios Network Error` | 39 |
|
||||
| `Cannot read properties of undefined (reading 'clientHeight')` | 6 |
|
||||
| 视频模块 `Cannot read properties of null (reading 'play')` | 1 |
|
||||
|
||||
多数页面最终仍能显示,但这些错误可能造成偶发空白、数据加载延迟、视频自动播放失败或隐性接口失败。
|
||||
|
||||
建议:在浏览器 Network 面板和服务端日志中定位具体失败接口、HTTP 状态、CORS、超时和重试情况;为异步页面增加加载骨架、错误提示和重试按钮;修复空 DOM 引用。
|
||||
|
||||
### P2:用工体检向第三方页面传递标识参数
|
||||
|
||||
“开始诊断”会整页跳转到腾讯问卷,并在 URL 中携带 `openid` 参数。
|
||||
|
||||
建议:由产品、法务和安全团队确认该参数是否属于个人标识、是否已在隐私政策中说明、腾讯问卷的数据处理角色和留存周期;建议使用短期一次性令牌,避免直接暴露稳定标识,并考虑新标签页打开或增加返回平台入口。
|
||||
|
||||
### P3:部分页面首次进入存在短暂空白/延迟加载
|
||||
|
||||
员工花名册、合同管理、模板库、通知管理等页面在进入后的约 0.7 秒内可能只显示框架,等待约 1.5—1.8 秒后数据才完整出现。
|
||||
|
||||
建议:增加统一加载状态或骨架屏,避免用户误以为“暂无数据”;同时结合上述网络错误排查性能瓶颈。
|
||||
|
||||
## 5. 建议的处理优先级
|
||||
|
||||
### 立即处理(当天)
|
||||
|
||||
1. 核实并处理 3 条超期合同。
|
||||
2. 核实已离职人员仍出现续签待办的原因。
|
||||
3. 对比首页 4 项与待办中心 3 项的统计明细。
|
||||
4. 确认医疗期计算器法律依据是否仍有效。
|
||||
|
||||
### 一周内处理
|
||||
|
||||
1. 修复背景调查 `-1次` 的展示或额度逻辑。
|
||||
2. 明确员工风险分布的统计口径。
|
||||
3. 将无固定期限合同显示为语义化状态,而不是 2099 年和数万天。
|
||||
4. 排查 Axios 网络错误和 `clientHeight` 空引用。
|
||||
5. 复核腾讯问卷 `openid` 参数的隐私与安全合规。
|
||||
|
||||
### 持续优化
|
||||
|
||||
1. 为延迟加载页面增加骨架屏和失败重试。
|
||||
2. 为关键统计增加“更新时间”和数据来源说明。
|
||||
3. 对合同、人员、待办三类状态建立每日自动一致性校验。
|
||||
4. 对法律计算器增加政策版本、适用地区、最后审核日期和免责声明。
|
||||
|
||||
## 6. 建议的回归测试用例
|
||||
|
||||
1. 离职员工完成离职后,续签待办是否自动关闭。
|
||||
2. 首页待办总数是否等于各分类待办之和。
|
||||
3. 背调额度为 0、正数、无限次时,文案和按钮状态是否正确。
|
||||
4. 无固定期限合同在合同列表和花名册中的显示是否一致。
|
||||
5. 花名册风险分布在分页、搜索、部门筛选前后是否符合定义。
|
||||
6. 医疗期政策更新后,上海和全国模式的计算结果是否经过法务复核。
|
||||
7. 慢网、接口失败、接口超时下是否显示加载/错误/重试状态。
|
||||
8. 视频详情页首次进入、自动播放被浏览器拦截、切换课程时是否仍有 `play` 空引用。
|
||||
9. 外部诊断问卷链接是否使用短期令牌,且返回平台流程正常。
|
||||
|
||||
## 7. 测试边界
|
||||
|
||||
以下项目因可能产生正式业务影响,本次只验证到入口或表单,不执行最终动作:
|
||||
|
||||
- AI 问题发送、转专家、判赔预测;
|
||||
- 文件审查上传;
|
||||
- 添加/导入/导出员工;
|
||||
- 发布考勤和工资条;
|
||||
- 保存或发布各类用工办理流程;
|
||||
- 合同下载、删除、续签、终止和自定义签署;
|
||||
- 发起背景调查;
|
||||
- 发布民主程序和通知;
|
||||
- 提交腾讯诊断问卷;
|
||||
- 使用模板正式发起流程。
|
||||
|
||||
如需继续做“可产生真实数据”的验收,建议先创建专用测试企业和测试员工,再按上述回归用例执行提交、短信、小程序待办、电子签署和撤销闭环测试。
|
||||
@@ -36,6 +36,8 @@ const MedicalPeriodCalculator = lazy(() => import('./pages/tools/MedicalPeriodCa
|
||||
const HealthCheck = lazy(() => import('./pages/tools/HealthCheck'))
|
||||
const AnnualValueReport = lazy(() => import('./pages/tools/AnnualValueReport'))
|
||||
const CalendarPage = lazy(() => import('./pages/Calendar'))
|
||||
const WorkProcess = lazy(() => import('./pages/WorkProcess'))
|
||||
const MyAttendance = lazy(() => import('./pages/portal/MyAttendance'))
|
||||
|
||||
// 平台管理端
|
||||
const PlatformLogin = lazy(() => import('./pages/platform/PlatformLogin'))
|
||||
@@ -152,6 +154,7 @@ export default function App() {
|
||||
<Route path="/tools/medical-period" element={<ProtectedRoute><AdminLayout><MedicalPeriodCalculator /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/tools/health-check" element={<ProtectedRoute><AdminLayout><HealthCheck /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/tools/annual-value" element={<ProtectedRoute><AdminLayout><AnnualValueReport /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/work-process" element={<ProtectedRoute><AdminLayout><WorkProcess /></AdminLayout></ProtectedRoute>} />
|
||||
|
||||
{/* 平台管理端 */}
|
||||
<Route path="/platform/login" element={<Suspense fallback={<SkeletonPage />}><PlatformLogin /></Suspense>} />
|
||||
@@ -166,6 +169,7 @@ export default function App() {
|
||||
<Route path="/portal/onboarding" element={<PortalLayoutWrapper showNav={false}><Onboarding /></PortalLayoutWrapper>} />
|
||||
<Route path="/portal/contract-confirm" element={<PortalLayoutWrapper showNav={false}><ContractConfirm /></PortalLayoutWrapper>} />
|
||||
<Route path="/portal/policies" element={<PortalLayoutWrapper><MyPolicies /></PortalLayoutWrapper>} />
|
||||
<Route path="/portal/attendance" element={<PortalLayoutWrapper><MyAttendance /></PortalLayoutWrapper>} />
|
||||
<Route path="/portal/auto-login" element={<PortalLayoutWrapper showNav={false}><AutoLogin /></PortalLayoutWrapper>} />
|
||||
|
||||
{/* 兜底 */}
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
*/
|
||||
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { DollarSign, FileText, ScrollText, LogOut } from 'lucide-react'
|
||||
import { DollarSign, FileText, ScrollText, LogOut, CalendarCheck } from 'lucide-react'
|
||||
import Logo from '../../components/ui/Logo'
|
||||
|
||||
const tabItems = [
|
||||
{ path: '/portal/payslip', label: '工资条', icon: DollarSign },
|
||||
{ path: '/portal/contract', label: '我的合同', icon: FileText },
|
||||
{ path: '/portal/attendance', label: '我的考勤', icon: CalendarCheck },
|
||||
{ path: '/portal/policies', label: '规章制度', icon: ScrollText },
|
||||
]
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Bot, BookMarked,
|
||||
Bell, ScrollText, Settings,
|
||||
ChevronDown, ChevronRight,
|
||||
Building2, CalendarDays,
|
||||
Building2, CalendarDays, ClipboardList,
|
||||
} from 'lucide-react'
|
||||
import Logo from '../ui/Logo'
|
||||
|
||||
@@ -40,6 +40,7 @@ const navGroups: NavGroup[] = [
|
||||
title: '员工管理',
|
||||
items: [
|
||||
{ path: '/roster', label: '花名册', icon: Users },
|
||||
{ path: '/work-process', label: '用工办理', icon: ClipboardList },
|
||||
{ path: '/attendance', label: '考勤确认', icon: CalendarCheck },
|
||||
{ path: '/termination', label: '解聘补偿', icon: UserX },
|
||||
],
|
||||
|
||||
@@ -1329,15 +1329,60 @@ function PredictTab() {
|
||||
)
|
||||
}
|
||||
|
||||
const REVIEW_DOC_TYPES = [
|
||||
{ value: 'labor_contract', label: '劳动合同' },
|
||||
{ value: 'rescission', label: '协商解除协议' },
|
||||
{ value: 'labor_service', label: '劳务协议' },
|
||||
{ value: 'internship', label: '实习协议' },
|
||||
{ value: 'nda', label: '保密协议' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]
|
||||
|
||||
function ReviewTab() {
|
||||
const [contractText, setContractText] = useState('')
|
||||
const [result, setResult] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [docType, setDocType] = useState('labor_contract')
|
||||
const [fileName, setFileName] = useState('')
|
||||
const [showSaveModal, setShowSaveModal] = useState(false)
|
||||
const [saveEmployeeId, setSaveEmployeeId] = useState('')
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const { history, saveMutation, deleteMutation, loadHistory } = useAIHistory('review')
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const ext = file.name.toLowerCase().split('.').pop()
|
||||
if (ext !== 'docx' && ext !== 'doc') {
|
||||
toast.error('仅支持 .docx 格式文件')
|
||||
return
|
||||
}
|
||||
if (file.size > 100 * 1024 * 1024) {
|
||||
toast.error('文件大小不能超过 100MB')
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const res = await api.post('/ai/review/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}) as any
|
||||
if (res.data?.text) {
|
||||
setContractText(res.data.text)
|
||||
setFileName(file.name)
|
||||
toast.success(`已提取文件内容(${res.data.text.length} 字)`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error?.message || '文件上传失败')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const { data: employees } = useQuery<any[]>({
|
||||
queryKey: ['roster-list'],
|
||||
queryFn: async () => {
|
||||
@@ -1410,8 +1455,23 @@ function ReviewTab() {
|
||||
<HistoryBar history={history || []} onLoad={handleLoadHistory} onDelete={(id) => deleteMutation.mutate(id)} />
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<Label>粘贴合同条款文本</Label>
|
||||
<div className="mt-3 space-y-3">
|
||||
{/* 文件上传区 */}
|
||||
<div>
|
||||
<Label>上传文件审查(可选)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={docType} onChange={(e) => setDocType(e.target.value)} className="w-40">
|
||||
{REVIEW_DOC_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
</Select>
|
||||
<input ref={fileInputRef} type="file" accept=".docx,.doc" onChange={handleFileUpload} className="hidden" />
|
||||
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
|
||||
{uploading ? (<><Loader2 className="w-4 h-4 animate-spin mr-1" />提取中...</>) : (<><FileText className="w-4 h-4 mr-1" />上传 .docx 文件</>)}
|
||||
</Button>
|
||||
{fileName && <span className="text-xs text-gray-500 truncate max-w-[200px]">{fileName}</span>}
|
||||
</div>
|
||||
</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-xs min-h-[200px] resize-y"
|
||||
placeholder="粘贴劳动合同文本..."
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { CalendarCheck, CheckCircle, Clock, AlertCircle, Plus, Trash2, Calendar, Users, BarChart3, Plane, Upload, Download, X } from 'lucide-react'
|
||||
import { CalendarCheck, CheckCircle, Clock, AlertCircle, Plus, Trash2, Calendar, Users, BarChart3, Plane, Upload, Download, X, Send, Loader2 } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import Card from '../components/ui/Card'
|
||||
@@ -124,9 +124,53 @@ function ConfirmTab() {
|
||||
},
|
||||
})
|
||||
|
||||
const { data: publishRecords } = useQuery<any[]>({
|
||||
queryKey: ['attendance-publish-records'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/attendance/publish-records') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const publishMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await api.post('/attendance/publish', { month }) as any
|
||||
return res.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(`${month}月考勤表已发布`)
|
||||
queryClient.invalidateQueries({ queryKey: ['attendance-publish-records'] })
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '发布失败'),
|
||||
})
|
||||
|
||||
const cancelPublishMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const res = await api.post(`/attendance/publish/${id}/cancel`) as any
|
||||
return res.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('已取消发布')
|
||||
queryClient.invalidateQueries({ queryKey: ['attendance-publish-records'] })
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '取消失败'),
|
||||
})
|
||||
|
||||
const currentPublish = publishRecords?.find((r: any) => r.month === month && r.status === 'PUBLISHED')
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
{currentPublish ? (
|
||||
<Button size="sm" variant="secondary" onClick={() => cancelPublishMutation.mutate(currentPublish.id)}>
|
||||
<X className="w-3.5 h-3.5 mr-1" />取消发布
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={() => publishMutation.mutate()} disabled={publishMutation.isPending}>
|
||||
{publishMutation.isPending ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Send className="w-3.5 h-3.5 mr-1" />}
|
||||
发布考勤表
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowImport(true)}>
|
||||
<Upload className="w-3.5 h-3.5 mr-1" />导入考勤
|
||||
</Button>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, RadialBarChart, RadialBar, PolarAngleAxis } from 'recharts'
|
||||
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert, UserPlus, AlertCircle, Download, ChevronRight, TrendingDown, ShieldCheck, Lightbulb, BookOpen, Sparkles } from 'lucide-react'
|
||||
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert, UserPlus, AlertCircle, Download, ChevronRight, TrendingDown, ShieldCheck, Lightbulb, BookOpen, Sparkles, Repeat, XCircle, Loader2 } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import Card from '../components/ui/Card'
|
||||
@@ -41,6 +41,8 @@ export default function Dashboard() {
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'payroll' | 'risk' | 'task'>('overview')
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [drillDownType, setDrillDownType] = useState<string | null>(null)
|
||||
const [showExpiringModal, setShowExpiringModal] = useState(false)
|
||||
const [dismissedExpiring, setDismissedExpiring] = useState(false)
|
||||
const { data, isLoading, refetch, isFetching } = useQuery<DashboardData>({
|
||||
queryKey: ['dashboard'],
|
||||
queryFn: async () => {
|
||||
@@ -441,30 +443,45 @@ export default function Dashboard() {
|
||||
</div>
|
||||
|
||||
{/* 合同到期预警 */}
|
||||
{expiringContracts && expiringContracts.length > 0 && (
|
||||
<Link to="/roster?contractStatus=expiring">
|
||||
<Card className="border-danger/30 bg-danger/5 hover:bg-danger/10 transition-colors cursor-pointer">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-danger" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-danger">合同到期预警</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
{expiringContracts.slice(0, 3).map((c: any, i: number) => (
|
||||
<span key={c.employeeId}>
|
||||
{i > 0 && '、'}
|
||||
{c.employeeName}
|
||||
<span className="text-danger ml-1">({c.daysLeft}天)</span>
|
||||
</span>
|
||||
))}
|
||||
{expiringContracts.length > 3 && <span className="text-gray-500"> 等{expiringContracts.length}人</span>}
|
||||
</div>
|
||||
{expiringContracts && expiringContracts.length > 0 && !dismissedExpiring && (
|
||||
<Card className="border-danger/30 bg-danger/5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer flex-1"
|
||||
onClick={() => setShowExpiringModal(true)}
|
||||
>
|
||||
<AlertCircle className="w-5 h-5 text-danger" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-danger">合同到期预警</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
{expiringContracts.slice(0, 3).map((c: any, i: number) => (
|
||||
<span key={c.employeeId}>
|
||||
{i > 0 && '、'}
|
||||
{c.employeeName}
|
||||
<span className="text-danger ml-1">({c.daysLeft}天)</span>
|
||||
</span>
|
||||
))}
|
||||
{expiringContracts.length > 3 && <span className="text-gray-500"> 等{expiringContracts.length}人</span>}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-danger" />
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowExpiringModal(true)}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
去处理
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDismissedExpiring(true)}
|
||||
className="text-gray-400 hover:text-gray-600 p-1"
|
||||
title="稍后提醒"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 本月工作动态 + 风险分布 左右两列 */}
|
||||
@@ -1015,6 +1032,95 @@ export default function Dashboard() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 合同到期处理弹窗 */}
|
||||
{showExpiringModal && expiringContracts && (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setShowExpiringModal(false)}>
|
||||
<Card className="max-w-2xl w-full max-h-[80vh] overflow-y-auto">
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-danger" />
|
||||
<h3 className="text-sm font-medium">合同到期处理</h3>
|
||||
<span className="text-xs text-gray-500">({expiringContracts.length}人)</span>
|
||||
</div>
|
||||
<button onClick={() => setShowExpiringModal(false)} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{expiringContracts.map((c: any) => (
|
||||
<div key={c.employeeId} className="flex items-center gap-3 p-3 rounded-md border border-gray-200">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">{c.employeeName}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{c.department} · 到期日:{c.endDate ? new Date(c.endDate).toLocaleDateString('zh-CN') : '未知'}
|
||||
<span className={`ml-2 ${c.daysLeft <= 7 ? 'text-danger' : c.daysLeft <= 30 ? 'text-warning' : 'text-gray-500'}`}>
|
||||
剩余 {c.daysLeft} 天
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
api.post('/work-processes', {
|
||||
type: 'RENEW',
|
||||
title: `合同续签-${c.employeeName}`,
|
||||
employeeId: c.employeeId,
|
||||
formData: { employeeId: c.employeeId, oldContractId: c.contractId },
|
||||
status: 'DRAFT',
|
||||
}).then(() => {
|
||||
toast.success(`已创建 ${c.employeeName} 的续签流程`)
|
||||
setShowExpiringModal(false)
|
||||
}).catch((err) => {
|
||||
toast.error(err?.response?.data?.error?.message || '创建失败')
|
||||
})
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs rounded text-primary hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
<Repeat className="w-3 h-3" />续签
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
api.post('/work-processes', {
|
||||
type: 'TERMINATE',
|
||||
title: `合同终止-${c.employeeName}`,
|
||||
employeeId: c.employeeId,
|
||||
formData: { employeeId: c.employeeId, contractId: c.contractId },
|
||||
status: 'DRAFT',
|
||||
}).then(() => {
|
||||
toast.success(`已创建 ${c.employeeName} 的终止流程`)
|
||||
setShowExpiringModal(false)
|
||||
}).catch((err) => {
|
||||
toast.error(err?.response?.data?.error?.message || '创建失败')
|
||||
})
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs rounded text-danger hover:bg-danger/10 transition-colors"
|
||||
>
|
||||
<XCircle className="w-3 h-3" />终止
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t flex items-center justify-between">
|
||||
<Link
|
||||
to="/roster?contractStatus=expiring"
|
||||
onClick={() => setShowExpiringModal(false)}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
查看全部合同 →
|
||||
</Link>
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowExpiringModal(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -547,6 +547,27 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '取消归档失败'),
|
||||
})
|
||||
|
||||
const publishPayslipMutation = useMutation({
|
||||
mutationFn: () => api.post(`/payroll2/batches/${batchId}/publish`),
|
||||
onSuccess: (res: any) => {
|
||||
toast.success(`已发布 ${res.data?.published || 0} 条工资条`)
|
||||
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '发布失败'),
|
||||
})
|
||||
|
||||
const [showScheduleModal, setShowScheduleModal] = useState(false)
|
||||
const [scheduleDate, setScheduleDate] = useState('')
|
||||
const schedulePayslipMutation = useMutation({
|
||||
mutationFn: () => api.post(`/payroll2/batches/${batchId}/schedule`, { scheduledAt: scheduleDate }),
|
||||
onSuccess: (res: any) => {
|
||||
toast.success(`已设定定时发送 ${res.data?.scheduled || 0} 条工资条`)
|
||||
setShowScheduleModal(false)
|
||||
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '设定失败'),
|
||||
})
|
||||
|
||||
const importOvertimeMutation = useMutation({
|
||||
mutationFn: () => api.post(`/payroll/overtime/import-to-batch/${batchId}`),
|
||||
onSuccess: (res: any) => {
|
||||
@@ -890,6 +911,24 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
||||
>
|
||||
{unarchiveMutation.isPending ? '取消中...' : '取消归档'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: '发布工资条', message: `确认发布 ${batch.month} 月工资条?发布后员工可在员工端查看。`, variant: 'primary' })) {
|
||||
publishPayslipMutation.mutate()
|
||||
}
|
||||
}}
|
||||
disabled={publishPayslipMutation.isPending}
|
||||
>
|
||||
{publishPayslipMutation.isPending ? '发布中...' : '发布工资条'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowScheduleModal(true)}
|
||||
>
|
||||
<Clock className="w-4 h-4 mr-1" />定时发送
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1042,6 +1081,41 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 定时发送弹窗 */}
|
||||
{showScheduleModal && (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setShowScheduleModal(false)}>
|
||||
<Card className="max-w-md w-full" >
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium">定时发送工资条</h3>
|
||||
<button onClick={() => setShowScheduleModal(false)} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-3">设定发送时间后,工资条将在指定时间自动发布到员工端</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>发送时间</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={scheduleDate}
|
||||
onChange={(e) => setScheduleDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!scheduleDate) { toast.error('请选择发送时间'); return }
|
||||
schedulePayslipMutation.mutate()
|
||||
}}
|
||||
disabled={schedulePayslipMutation.isPending}
|
||||
>
|
||||
{schedulePayslipMutation.isPending ? '设定中...' : '确认定时发送'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { FileText, Copy, X, ChevronRight, Download, BookOpen, HelpCircle } from 'lucide-react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { FileText, Copy, X, ChevronRight, Download, BookOpen, HelpCircle, Plus, Edit, Trash2, Building2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import api from '../lib/api'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label, Select } from '../components/ui/Input'
|
||||
import Modal from '../components/ui/Modal'
|
||||
import EmptyState from '../components/ui/EmptyState'
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
@@ -68,6 +70,37 @@ const VARIABLE_LABELS: Record<string, string> = {
|
||||
* 用工文本模板库页面
|
||||
*/
|
||||
export default function Templates() {
|
||||
const [tab, setTab] = useState<'system' | 'enterprise'>('system')
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-base font-semibold">用工文本模板库</h1>
|
||||
</div>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setTab('system')}
|
||||
className={`px-4 py-1.5 text-sm rounded-lg ${tab === 'system' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||
>
|
||||
系统模板库
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('enterprise')}
|
||||
className={`px-4 py-1.5 text-sm rounded-lg ${tab === 'enterprise' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||
>
|
||||
<Building2 className="w-3.5 h-3.5 inline mr-1" />企业文本库
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'system' ? <SystemTemplates /> : <EnterpriseTemplates />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SystemTemplates() {
|
||||
const [category, setCategory] = useState<string>('')
|
||||
const [selected, setSelected] = useState<any>(null)
|
||||
const [rendered, setRendered] = useState<string>('')
|
||||
@@ -137,10 +170,6 @@ export default function Templates() {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-base font-semibold">用工文本模板库</h1>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">合同、制度、通知等常用文本模板,支持变量替换</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -270,3 +299,264 @@ export default function Templates() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EnterpriseTemplates() {
|
||||
const queryClient = useQueryClient()
|
||||
const [category, setCategory] = useState<string>('')
|
||||
const [showEdit, setShowEdit] = useState(false)
|
||||
const [editItem, setEditItem] = useState<any>(null)
|
||||
const [form, setForm] = useState({ name: '', category: 'CONTRACT', description: '', content: '' })
|
||||
const [selected, setSelected] = useState<any>(null)
|
||||
const [rendered, setRendered] = useState('')
|
||||
const [variables, setVariables] = useState<Record<string, string>>({})
|
||||
|
||||
const { data: list, isLoading } = useQuery<any>({
|
||||
queryKey: ['enterprise-templates', category],
|
||||
queryFn: async () => {
|
||||
const params = category ? `?category=${category}` : ''
|
||||
const res = await api.get(`/enterprise-templates${params}`) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: detail } = useQuery<any>({
|
||||
queryKey: ['enterprise-template-detail', selected?.id],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/enterprise-templates/${selected.id}`) as any
|
||||
return res.data
|
||||
},
|
||||
enabled: !!selected,
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
if (editItem) {
|
||||
const res = await api.put(`/enterprise-templates/${editItem.id}`, data) as any
|
||||
return res.data
|
||||
} else {
|
||||
const res = await api.post('/enterprise-templates', data) as any
|
||||
return res.data
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(editItem ? '已更新' : '已创建')
|
||||
queryClient.invalidateQueries({ queryKey: ['enterprise-templates'] })
|
||||
setShowEdit(false)
|
||||
setEditItem(null)
|
||||
setForm({ name: '', category: 'CONTRACT', description: '', content: '' })
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '操作失败'),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await api.delete(`/enterprise-templates/${id}`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('已删除')
|
||||
queryClient.invalidateQueries({ queryKey: ['enterprise-templates'] })
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
|
||||
})
|
||||
|
||||
const handleRender = async () => {
|
||||
if (!selected) return
|
||||
try {
|
||||
const res = await api.post(`/enterprise-templates/${selected.id}/render`, { variables }) as any
|
||||
setRendered(res.data.content)
|
||||
} catch {
|
||||
toast.error('渲染失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadWord = async () => {
|
||||
if (!selected) return
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
|
||||
const res = await fetch(`${baseURL}/enterprise-templates/${selected.id}/download`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${selected.name}.doc`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('已下载')
|
||||
} catch {
|
||||
toast.error('下载失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (item: any) => {
|
||||
setEditItem(item)
|
||||
setForm({ name: item.name, category: item.category, description: item.description || '', content: item.content })
|
||||
setShowEdit(true)
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditItem(null)
|
||||
setForm({ name: '', category: 'CONTRACT', description: '', content: '' })
|
||||
setShowEdit(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">企业自建文本模板,支持变量替换和 Word 下载</p>
|
||||
<Button size="sm" onClick={handleAdd}>
|
||||
<Plus className="w-4 h-4 mr-1" />新建模板
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{['', 'CONTRACT', 'RULES', 'NOTICE', 'AGREEMENT', 'OTHER'].map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCategory(c)}
|
||||
className={`px-3 py-1 text-xs rounded-lg ${category === c ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||
>
|
||||
{c === '' ? '全部' : CATEGORY_LABELS[c]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||||
) : !list || list.length === 0 ? (
|
||||
<EmptyState title="暂无企业模板" description="点击「新建模板」创建您的第一个企业文本模板" />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{list.map((t: any) => (
|
||||
<Card key={t.id} className="hover:shadow-md transition-shadow">
|
||||
<div onClick={() => { setSelected(t); setRendered(''); setVariables({}) }} className="cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-1.5 py-0.5 rounded text-xs bg-primary/10 text-primary">{CATEGORY_LABELS[t.category]}</span>
|
||||
<span className="text-sm font-medium truncate flex-1">{t.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">{t.description}</p>
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-gray-400">
|
||||
{t.variables?.slice(0, 4).map((v: string) => (
|
||||
<span key={v} className="px-1 py-0.5 rounded bg-gray-100">{VARIABLE_LABELS[v] || v}</span>
|
||||
))}
|
||||
{t.variables?.length > 4 && <span>+{t.variables.length - 4}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-gray-100">
|
||||
<button onClick={() => handleEdit(t)} className="flex items-center gap-1 text-xs text-gray-500 hover:text-primary">
|
||||
<Edit className="w-3 h-3" />编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm('确认删除?')) deleteMutation.mutate(t.id) }}
|
||||
className="flex items-center gap-1 text-xs text-gray-500 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />删除
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
<Modal open={showEdit} onClose={() => setShowEdit(false)} title={editItem ? '编辑模板' : '新建模板'} size="lg">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>模板名称</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="如:员工保密协议" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>分类</Label>
|
||||
<Select value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
|
||||
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>描述</Label>
|
||||
<Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="简要描述模板用途" />
|
||||
</div>
|
||||
<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 text-sm min-h-[200px] font-mono"
|
||||
value={form.content}
|
||||
onChange={(e) => setForm({ ...form, content: e.target.value })}
|
||||
placeholder="输入模板内容,使用 {{变量名}} 作为变量占位符,如 {{employeeName}}、{{companyName}}"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
变量格式:<code className="px-1 bg-gray-100 rounded">{'{{变量名}}'}</code>,如 <code className="px-1 bg-gray-100 rounded">{'{{employeeName}}'}</code>、<code className="px-1 bg-gray-100 rounded">{'{{companyName}}'}</code>
|
||||
</div>
|
||||
<Button onClick={() => saveMutation.mutate(form)} disabled={saveMutation.isPending}>
|
||||
{saveMutation.isPending ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
{selected && (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setSelected(null)}>
|
||||
<Card className="max-w-3xl w-full max-h-[85vh] overflow-y-auto">
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-medium">{selected.name}</h2>
|
||||
<button onClick={() => setSelected(null)} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
|
||||
{detail?.variables && detail.variables.length > 0 && (
|
||||
<div className="mb-3 space-y-2">
|
||||
<div className="text-xs font-medium text-gray-600">填写变量</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{detail.variables.map((v: string) => (
|
||||
<div key={v}>
|
||||
<label className="text-xs text-gray-500">{VARIABLE_LABELS[v] || v}</label>
|
||||
<input
|
||||
value={variables[v] || ''}
|
||||
onChange={e => setVariables(prev => ({ ...prev, [v]: e.target.value }))}
|
||||
className="w-full px-2 py-1 text-sm border rounded focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
placeholder={`输入${VARIABLE_LABELS[v] || v}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button size="sm" onClick={handleRender}>渲染模板</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rendered ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-600">渲染结果</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleDownloadWord} className="flex items-center gap-1 text-xs text-primary hover:underline">
|
||||
<Download className="w-3 h-3" />下载 Word
|
||||
</button>
|
||||
<button onClick={() => { navigator.clipboard.writeText(rendered); toast.success('已复制') }} className="flex items-center gap-1 text-xs text-primary hover:underline">
|
||||
<Copy className="w-3 h-3" />复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 p-3 rounded-lg max-h-[50vh] overflow-y-auto">{rendered}</pre>
|
||||
</div>
|
||||
) : detail?.content ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-600">模板原文</span>
|
||||
<button onClick={handleDownloadWord} className="flex items-center gap-1 text-xs text-primary hover:underline">
|
||||
<Download className="w-3 h-3" />下载 Word
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 p-3 rounded-lg max-h-[50vh] overflow-y-auto">{detail.content}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
UserPlus, LogIn, FileSignature, Edit, CheckCircle, RefreshCw,
|
||||
Repeat, Pause, FileText, XCircle, UserX, FileMinus, Briefcase,
|
||||
Loader2, ChevronRight, Trash2, Send, X, Eye,
|
||||
} 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'
|
||||
|
||||
const PROCESS_ICONS: Record<string, any> = {
|
||||
HIRE: UserPlus, ONBOARD: LogIn, CUSTOM_CONTRACT: FileSignature,
|
||||
INFO_SUBMIT: Edit, CONFIRM: CheckCircle, CHANGE: RefreshCw,
|
||||
RENEW: Repeat, SUSPEND: Pause, INCOME_CERT: FileText,
|
||||
TERMINATE: XCircle, RESCIND: UserX, LEAVING_CERT: FileMinus,
|
||||
FLEXIBLE: Briefcase,
|
||||
}
|
||||
|
||||
const PROCESS_TYPES: Record<string, { label: string; description: string }> = {
|
||||
HIRE: { label: '员工录用', description: '录用新员工并起草劳动合同' },
|
||||
ONBOARD: { label: '员工入职', description: '办理员工入职手续' },
|
||||
CUSTOM_CONTRACT: { label: '自定义合同签署', description: '自定义合同内容并签署' },
|
||||
INFO_SUBMIT: { label: '员工信息提交', description: '提交员工信息变更' },
|
||||
CONFIRM: { label: '员工转正', description: '试用期员工转正' },
|
||||
CHANGE: { label: '合同变更', description: '变更合同内容' },
|
||||
RENEW: { label: '合同续签', description: '到期合同续签' },
|
||||
SUSPEND: { label: '合同中止', description: '中止履行合同' },
|
||||
INCOME_CERT: { label: '开具收入证明', description: '为员工开具收入证明' },
|
||||
TERMINATE: { label: '合同终止', description: '合同到期终止' },
|
||||
RESCIND: { label: '合同解除', description: '协商或单方解除合同' },
|
||||
LEAVING_CERT: { label: '开具离职证明', description: '为离职员工开具证明' },
|
||||
FLEXIBLE: { label: '灵活用工', description: '灵活用工协议签署' },
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
DRAFT: { label: '草稿', color: 'bg-gray-100 text-gray-600' },
|
||||
PENDING_APPROVAL: { label: '待审批', color: 'bg-amber-50 text-amber-700' },
|
||||
APPROVED: { label: '已审批', color: 'bg-blue-50 text-blue-700' },
|
||||
REJECTED: { label: '已驳回', color: 'bg-red-50 text-red-700' },
|
||||
EXECUTING: { label: '执行中', color: 'bg-purple-50 text-purple-700' },
|
||||
COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe' },
|
||||
CANCELLED: { label: '已撤销', color: 'bg-gray-100 text-gray-400' },
|
||||
}
|
||||
|
||||
// 各流程类型的表单字段配置
|
||||
const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | 'date' | 'number' | 'select' | 'textarea'; options?: string[] }[]> = {
|
||||
HIRE: [
|
||||
{ key: 'name', label: '员工姓名', type: 'text' },
|
||||
{ key: 'department', label: '部门', type: 'text' },
|
||||
{ key: 'hireDate', label: '入职日期', type: 'date' },
|
||||
{ key: 'monthlySalary', label: '月薪', type: 'number' },
|
||||
{ key: 'phone', label: '手机号', type: 'text' },
|
||||
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
|
||||
{ key: 'gender', label: '性别', type: 'select', options: ['男', '女'] },
|
||||
{ key: 'contractStartDate', label: '合同开始日期', type: 'date' },
|
||||
{ key: 'contractEndDate', label: '合同结束日期', type: 'date' },
|
||||
],
|
||||
ONBOARD: [
|
||||
{ key: 'employeeId', label: '员工ID', type: 'text' },
|
||||
{ key: 'hireDate', label: '入职日期', type: 'date' },
|
||||
],
|
||||
CUSTOM_CONTRACT: [
|
||||
{ key: 'employeeId', label: '员工ID', type: 'text' },
|
||||
{ key: 'contractStartDate', label: '合同开始日期', type: 'date' },
|
||||
{ key: 'contractEndDate', label: '合同结束日期', type: 'date' },
|
||||
{ key: 'contractType', label: '合同类型', type: 'select', options: ['FIXED', 'UNFIXED', 'INTERNSHIP'] },
|
||||
],
|
||||
INFO_SUBMIT: [
|
||||
{ key: 'employeeId', label: '员工ID', type: 'text' },
|
||||
{ key: 'department', label: '部门', type: 'text' },
|
||||
{ key: 'phone', label: '手机号', type: 'text' },
|
||||
{ key: 'address', label: '地址', type: 'text' },
|
||||
{ key: 'emergencyContact', label: '紧急联系人', type: 'text' },
|
||||
{ key: 'emergencyPhone', label: '紧急联系电话', type: 'text' },
|
||||
],
|
||||
CONFIRM: [
|
||||
{ key: 'employeeId', label: '员工ID', type: 'text' },
|
||||
{ key: 'confirmDate', label: '转正日期', type: 'date' },
|
||||
{ key: 'regularSalary', label: '转正薪资', type: 'number' },
|
||||
],
|
||||
CHANGE: [
|
||||
{ key: 'contractId', label: '合同ID', type: 'text' },
|
||||
{ key: 'newEndDate', label: '新到期日期', type: 'date' },
|
||||
],
|
||||
RENEW: [
|
||||
{ key: 'employeeId', label: '员工ID', type: 'text' },
|
||||
{ key: 'oldContractId', label: '原合同ID', type: 'text' },
|
||||
{ key: 'newStartDate', label: '新合同开始日期', type: 'date' },
|
||||
{ key: 'newEndDate', label: '新合同结束日期', type: 'date' },
|
||||
{ key: 'newSalary', label: '新薪资', type: 'number' },
|
||||
],
|
||||
SUSPEND: [
|
||||
{ key: 'contractId', label: '合同ID', type: 'text' },
|
||||
{ key: 'suspendDate', label: '中止日期', type: 'date' },
|
||||
],
|
||||
INCOME_CERT: [
|
||||
{ key: 'employeeName', label: '员工姓名', type: 'text' },
|
||||
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
|
||||
{ key: 'position', label: '职务', type: 'text' },
|
||||
{ key: 'monthlyIncome', label: '月收入', type: 'text' },
|
||||
{ key: 'purpose', label: '用途', type: 'text' },
|
||||
],
|
||||
TERMINATE: [
|
||||
{ key: 'employeeId', label: '员工ID', type: 'text' },
|
||||
{ key: 'contractId', label: '合同ID', type: 'text' },
|
||||
{ key: 'terminateDate', label: '终止日期', type: 'date' },
|
||||
],
|
||||
RESCIND: [
|
||||
{ key: 'employeeId', label: '员工ID', type: 'text' },
|
||||
{ key: 'contractId', label: '合同ID', type: 'text' },
|
||||
{ key: 'rescindDate', label: '解除日期', type: 'date' },
|
||||
],
|
||||
LEAVING_CERT: [
|
||||
{ key: 'employeeName', label: '员工姓名', type: 'text' },
|
||||
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
|
||||
{ key: 'position', label: '职务', type: 'text' },
|
||||
{ key: 'hireDate', label: '入职日期', type: 'date' },
|
||||
{ key: 'leaveDate', label: '离职日期', type: 'date' },
|
||||
],
|
||||
FLEXIBLE: [
|
||||
{ key: 'name', label: '姓名', type: 'text' },
|
||||
{ key: 'phone', label: '手机号', type: 'text' },
|
||||
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
|
||||
{ key: 'department', label: '部门', type: 'text' },
|
||||
{ key: 'agreementStartDate', label: '协议开始日期', type: 'date' },
|
||||
{ key: 'agreementEndDate', label: '协议结束日期', type: 'date' },
|
||||
{ key: 'payMethod', label: '计酬方式', type: 'text' },
|
||||
],
|
||||
}
|
||||
|
||||
export default function WorkProcess() {
|
||||
const queryClient = useQueryClient()
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [selectedType, setSelectedType] = useState<string>('')
|
||||
const [formData, setFormData] = useState<Record<string, any>>({})
|
||||
const [filterType, setFilterType] = useState('')
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
const [detailId, setDetailId] = useState<string | null>(null)
|
||||
const [previewContent, setPreviewContent] = useState<string | null>(null)
|
||||
|
||||
const { data: listData, isLoading } = useQuery({
|
||||
queryKey: ['work-processes', filterType, filterStatus],
|
||||
queryFn: async () => {
|
||||
const params: any = {}
|
||||
if (filterType) params.type = filterType
|
||||
if (filterStatus) params.status = filterStatus
|
||||
const res = await api.get('/work-processes', { params }) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
const res = await api.post('/work-processes', data) as any
|
||||
return res.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('已创建草稿')
|
||||
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
|
||||
setShowCreate(false)
|
||||
setFormData({})
|
||||
setSelectedType('')
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '创建失败'),
|
||||
})
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const res = await api.post(`/work-processes/${id}/submit`) as any
|
||||
return res.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('已提交并执行')
|
||||
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
|
||||
setDetailId(null)
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '提交失败'),
|
||||
})
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const res = await api.post(`/work-processes/${id}/cancel`) as any
|
||||
return res.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('已撤销')
|
||||
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
|
||||
setDetailId(null)
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '撤销失败'),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await api.delete(`/work-processes/${id}`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('已删除')
|
||||
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
|
||||
})
|
||||
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const res = await api.get(`/work-processes/${id}/preview`) as any
|
||||
return res.data
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setPreviewContent(data.content)
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '预览失败'),
|
||||
})
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!selectedType) {
|
||||
toast.error('请选择流程类型')
|
||||
return
|
||||
}
|
||||
createMutation.mutate({
|
||||
type: selectedType,
|
||||
title: PROCESS_TYPES[selectedType].label,
|
||||
formData,
|
||||
status: 'DRAFT',
|
||||
})
|
||||
}
|
||||
|
||||
const handleFieldChange = (key: string, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
const items = listData?.items || []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 发起办理 */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-medium">用工办理</h2>
|
||||
<Button size="sm" onClick={() => setShowCreate(true)}>
|
||||
<UserPlus className="w-4 h-4 mr-1" />发起办理
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 13类流程卡片 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||
{Object.entries(PROCESS_TYPES).map(([key, config]) => {
|
||||
const Icon = PROCESS_ICONS[key] || FileText
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => {
|
||||
setSelectedType(key)
|
||||
setShowCreate(true)
|
||||
setFormData({})
|
||||
}}
|
||||
className="flex items-start gap-2 p-3 rounded-md border border-gray-200 hover:border-primary hover:bg-primary/5 transition-colors text-left"
|
||||
>
|
||||
<Icon className="w-4 h-4 text-primary mt-0.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-gray-900">{config.label}</div>
|
||||
<div className="text-[10px] text-gray-500 truncate">{config.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 办理记录 */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<h3 className="text-sm font-medium">办理记录</h3>
|
||||
<Select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="w-32">
|
||||
<option value="">全部类型</option>
|
||||
{Object.entries(PROCESS_TYPES).map(([key, config]) => (
|
||||
<option key={key} value={key}>{config.label}</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} className="w-32">
|
||||
<option value="">全部状态</option>
|
||||
{Object.entries(STATUS_CONFIG).map(([key, config]) => (
|
||||
<option key={key} value={key}>{config.label}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8"><Loader2 className="w-5 h-5 animate-spin text-gray-400" /></div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center py-8 text-sm text-gray-400">暂无办理记录</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((item: any) => {
|
||||
const Icon = PROCESS_ICONS[item.type] || FileText
|
||||
const statusCfg = STATUS_CONFIG[item.status] || STATUS_CONFIG.DRAFT
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-3 p-3 rounded-md border border-gray-200 hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => setDetailId(item.id)}
|
||||
>
|
||||
<Icon className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900">{item.title}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${statusCfg.color}`}>{statusCfg.label}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{item.employee ? `${item.employee.name} · ${item.employee.department}` : '未关联员工'}
|
||||
{' · '}{new Date(item.createdAt).toLocaleDateString('zh-CN')}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-300" />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 创建/编辑弹窗 */}
|
||||
<Modal open={showCreate} onClose={() => { setShowCreate(false); setFormData({}); setSelectedType('') }} title={selectedType ? `发起:${PROCESS_TYPES[selectedType]?.label}` : '发起办理'} size="lg">
|
||||
{!selectedType ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{Object.entries(PROCESS_TYPES).map(([key, config]) => {
|
||||
const Icon = PROCESS_ICONS[key] || FileText
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setSelectedType(key)}
|
||||
className="flex items-start gap-2 p-3 rounded-md border border-gray-200 hover:border-primary hover:bg-primary/5 transition-colors text-left"
|
||||
>
|
||||
<Icon className="w-4 h-4 text-primary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<div className="text-xs font-medium">{config.label}</div>
|
||||
<div className="text-[10px] text-gray-500">{config.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-gray-500 mb-2">{PROCESS_TYPES[selectedType]?.description}</div>
|
||||
{(FORM_FIELDS[selectedType] || []).map(field => (
|
||||
<div key={field.key}>
|
||||
<Label>{field.label}</Label>
|
||||
{field.type === 'select' ? (
|
||||
<Select value={formData[field.key] || ''} onChange={(e) => handleFieldChange(field.key, e.target.value)}>
|
||||
<option value="">请选择</option>
|
||||
{field.options?.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</Select>
|
||||
) : field.type === 'textarea' ? (
|
||||
<textarea
|
||||
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary text-sm min-h-[80px]"
|
||||
value={formData[field.key] || ''}
|
||||
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
|
||||
value={formData[field.key] || ''}
|
||||
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button onClick={handleCreate} disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : null}
|
||||
存草稿
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => { setSelectedType(''); setFormData({}) }}>
|
||||
返回选择
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
<Modal open={!!detailId} onClose={() => { setDetailId(null); setPreviewContent(null) }} title="办理详情" size="lg">
|
||||
<DetailContent
|
||||
id={detailId}
|
||||
previewContent={previewContent}
|
||||
onPreview={(id) => previewMutation.mutate(id)}
|
||||
onSubmit={(id) => submitMutation.mutate(id)}
|
||||
onCancel={(id) => cancelMutation.mutate(id)}
|
||||
onDelete={(id) => deleteMutation.mutate(id)}
|
||||
loading={submitMutation.isPending || cancelMutation.isPending}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailContent({ id, previewContent, onPreview, onSubmit, onCancel, onDelete, loading }: {
|
||||
id: string | null
|
||||
previewContent: string | null
|
||||
onPreview: (id: string) => void
|
||||
onSubmit: (id: string) => void
|
||||
onCancel: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
loading: boolean
|
||||
}) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['work-process', id],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/work-processes/${id}`) as any
|
||||
return res.data
|
||||
},
|
||||
enabled: !!id,
|
||||
})
|
||||
|
||||
if (isLoading || !data) return <div className="flex items-center justify-center py-8"><Loader2 className="w-5 h-5 animate-spin text-gray-400" /></div>
|
||||
|
||||
const statusCfg = STATUS_CONFIG[data.status] || STATUS_CONFIG.DRAFT
|
||||
const Icon = PROCESS_ICONS[data.type] || FileText
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className="w-5 h-5 text-primary" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{data.title}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${statusCfg.color}`}>{statusCfg.label}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{PROCESS_TYPES[data.type]?.label} · {data.employee ? `${data.employee.name}(${data.employee.department})` : '未关联员工'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 表单数据 */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-700 mb-2">表单信息</h4>
|
||||
<div className="bg-gray-50 rounded-md p-3 space-y-1">
|
||||
{Object.entries(data.formData || {}).map(([key, value]: [string, any]) => (
|
||||
<div key={key} className="flex text-xs">
|
||||
<span className="text-gray-500 w-28 shrink-0">{key}</span>
|
||||
<span className="text-gray-900">{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(data.formData || {}).length === 0 && <span className="text-xs text-gray-400">无表单数据</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文书预览 */}
|
||||
{previewContent && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-700 mb-2">文书预览</h4>
|
||||
<pre className="bg-gray-50 rounded-md p-3 text-xs whitespace-pre-wrap max-h-[300px] overflow-y-auto">{previewContent}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成的文书 */}
|
||||
{data.documents && data.documents.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-700 mb-2">已生成文书</h4>
|
||||
<div className="space-y-1">
|
||||
{data.documents.map((doc: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs">
|
||||
<FileText className="w-3 h-3 text-gray-400" />
|
||||
<span>{doc.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-2 pt-2 border-t">
|
||||
{data.status === 'DRAFT' && (
|
||||
<>
|
||||
<Button size="sm" onClick={() => onPreview(data.id)} variant="secondary">
|
||||
<Eye className="w-4 h-4 mr-1" />预览文书
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => onSubmit(data.id)} disabled={loading}>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Send className="w-4 h-4 mr-1" />}
|
||||
提交办理
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" onClick={() => onDelete(data.id)}>
|
||||
<Trash2 className="w-4 h-4 mr-1" />删除
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!['COMPLETED', 'CANCELLED'].includes(data.status) && data.status !== 'DRAFT' && (
|
||||
<Button size="sm" variant="secondary" onClick={() => onCancel(data.id)} disabled={loading}>
|
||||
<X className="w-4 h-4 mr-1" />撤销
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Loader2, CalendarCheck } from 'lucide-react'
|
||||
import api from '../../lib/api'
|
||||
|
||||
export default function MyAttendance() {
|
||||
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['portal-attendance', month],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/portal/attendance', { params: { month } }) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const records = data?.records || []
|
||||
const published = data?.published || false
|
||||
|
||||
// 生成月份列表(最近6个月)
|
||||
const months: string[] = []
|
||||
const now = new Date()
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||
months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarCheck className="w-5 h-5 text-primary" />
|
||||
<h1 className="text-base font-bold">我的考勤</h1>
|
||||
</div>
|
||||
|
||||
{/* 月份选择 */}
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{months.map(m => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMonth(m)}
|
||||
className={`px-3 py-1.5 rounded-md text-xs whitespace-nowrap transition-colors ${
|
||||
month === m
|
||||
? 'bg-primary text-white font-medium'
|
||||
: 'bg-white border border-gray-200 text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : !published ? (
|
||||
<div className="bg-white rounded-lg p-8 text-center">
|
||||
<p className="text-sm text-gray-400">{month} 月考勤表尚未发布</p>
|
||||
</div>
|
||||
) : records.length === 0 ? (
|
||||
<div className="bg-white rounded-lg p-8 text-center">
|
||||
<p className="text-sm text-gray-400">暂无考勤记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-100">
|
||||
<h2 className="text-sm font-medium">{data?.title || `${month} 月考勤表`}</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-50">
|
||||
{records.map((record: any) => (
|
||||
<div key={record.id} className="flex items-center px-4 py-2.5">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-gray-900">
|
||||
{new Date(record.date).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', weekday: 'short' })}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{record.checkInTime ? `上班 ${record.checkInTime}` : '未打卡'}
|
||||
{record.checkOutTime ? ` · 下班 ${record.checkOutTime}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||
record.status === 'NORMAL' ? 'bg-green-50 text-safe' :
|
||||
record.status === 'LATE' ? 'bg-amber-50 text-amber-700' :
|
||||
record.status === 'ABSENT' ? 'bg-red-50 text-red-700' :
|
||||
record.status === 'LEAVE' ? 'bg-blue-50 text-blue-700' :
|
||||
'bg-gray-50 text-gray-600'
|
||||
}`}>
|
||||
{record.statusText || record.status || '未知'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user