feat: 完成优化-4全部任务 + 多城市社保 + pgvector修复
- AIAssistant: 会话历史保存/加载/删除,风险预测支持范围筛选,审查结果保存到员工档案 - Dashboard: 待办批量操作,风险分布可下钻,刷新按钮Tab级联,薪税tab导出Excel - SocialInsurance: 多城市社保/公积金配置支持,城市选择器 - Roster: 新增参保城市字段 - risk.service: 修复风险项去重逻辑(用employeeId:type:actionUrl替代含动态天数的title) - payroll.routes: 修复OvertimeRecord/Payslip字段名错误 - pgvector: 从源码编译安装x86_64版本兼容postgresql@15 - 优化-4文档: 全部8项标记为已完成
This commit is contained in:
+28
-169
@@ -2,215 +2,74 @@
|
||||
|
||||
> **文档编号**: 20260723-优化-4.md
|
||||
> **日期**: 2026-07-23
|
||||
> **来源**: 对 Contracts.tsx、Compensation.tsx、Dashboard.tsx、AIAssistant.tsx 及相关后端服务深入研究后得出
|
||||
> **来源**: 对 Dashboard.tsx、AIAssistant.tsx 及相关后端服务深入研究后得出
|
||||
> **注意**: Contracts.tsx 和 Compensation.tsx 已无路由引用(功能已整合到 Roster 和 Termination),涉及这两个页面的条目已移除
|
||||
|
||||
---
|
||||
|
||||
## 一、高优先级(核心业务缺陷)
|
||||
|
||||
### 1. Contracts — 员工详情抽屉无编辑能力
|
||||
### 1. ✅ AIAssistant — 会话历史保存(已完成)
|
||||
|
||||
**现状**: `EmployeeDetailDrawer` 只展示员工基本信息、合同历史和附件,无法修改任何字段。员工特殊状态(孕期/医疗期/工伤)只能在「添加员工」时设置,后续无法更新,导致系统记录与实际脱节。
|
||||
|
||||
**建议**:
|
||||
- 在员工详情抽屉增加「编辑」按钮,打开编辑表单
|
||||
- 特殊状态字段改为可编辑,并记录变更时间
|
||||
- 支持修改联系方式、部门等基本信息
|
||||
|
||||
**涉及文件**: `frontend/src/pages/Contracts.tsx`、`backend/src/routes/employee.routes.ts`
|
||||
**状态**: 已实现会话历史保存功能。后端新增 `AIConversation` 表,前端 ChatTab 支持「新建对话」「历史会话」列表加载/切换/删除,消息自动 debounce 保存。
|
||||
|
||||
---
|
||||
|
||||
### 2. Compensation — 计算器与 Termination.tsx 重复实现
|
||||
### 2. ✅ Dashboard — 待办事项批量操作(已完成)
|
||||
|
||||
**现状**: 经济补偿金计算逻辑在 `Compensation.tsx` 的 `SeveranceCalculator` 和 `Termination.tsx` 的 `costResult` 中各实现一遍,且参数略有差异(前者有社平工资封顶,后者没有三倍封顶判断)。维护两套逻辑存在一致性问题。
|
||||
|
||||
**建议**:
|
||||
- 将经济补偿金计算逻辑抽取为共享的计算模块(`shared/compensation.ts`)
|
||||
- 前端统一调用共享模块,后端 `termination.service.ts` 的 `calculateCompensation` 也引用同一逻辑
|
||||
- 或改为调用后端 `/compensation/calculate` 接口,前端只负责展示
|
||||
|
||||
**涉及文件**: `frontend/src/pages/Compensation.tsx`、`frontend/src/pages/Termination.tsx`、`backend/src/services/termination.service.ts`
|
||||
|
||||
---
|
||||
|
||||
### 3. AIAssistant — 会话历史完全丢失
|
||||
|
||||
**现状**: `ChatTab` 的消息状态只在组件内维护,刷新页面或切换 Tab 后所有对话记录丢失。用户无法回顾之前的 AI 问答,也没法基于历史对话继续追问。
|
||||
|
||||
**建议**:
|
||||
- 后端增加会话历史存储表 `AIConversation`,记录 userId、messages 数组、createdAt
|
||||
- 前端加载时从 `/ai/conversations` 获取历史会话列表
|
||||
- 每次新对话自动保存,切换会话可恢复历史上下文
|
||||
- 增加「新建对话」和「历史会话」下拉列表
|
||||
|
||||
**涉及文件**: `frontend/src/pages/AIAssistant.tsx`、`backend/src/routes/ai.routes.ts`、`backend/prisma/schema.prisma`
|
||||
|
||||
---
|
||||
|
||||
### 4. Dashboard — 待办事项无批量操作
|
||||
|
||||
**现状**: 待办列表只能逐个「标记完成」或「忽略」。当 HR 需要批量处理同类风险项(如忽略所有合同即将过期的提醒)时,需重复点击 N 次,体验极差。
|
||||
|
||||
**建议**:
|
||||
- 增加「全选」复选框和批量操作栏(批量标记完成 / 批量忽略)
|
||||
- 增加「按类型批量处理」入口:点击「合同风险」标签,弹出确认框「忽略所有 {N} 项合同风险?」
|
||||
- 批量操作调用 `PATCH /dashboard/todos/batch-resolve` 或 `PATCH /dashboard/todos/batch-ignore`
|
||||
|
||||
**涉及文件**: `frontend/src/pages/Dashboard.tsx`、`backend/src/routes/dashboard.routes.ts`
|
||||
**状态**: 已实现批量操作功能。后端新增 `PATCH /dashboard/todos/batch-resolve` 和 `batch-ignore` 端点,前端待办列表增加全选复选框和批量操作按钮。
|
||||
|
||||
---
|
||||
|
||||
## 二、中优先级(高频操作体验)
|
||||
|
||||
### 5. Contracts — 无合同续签入口
|
||||
### 3. ✅ AIAssistant — 分析结果关联员工档案(已完成)
|
||||
|
||||
**现状**: 员工详情抽屉只展示合同历史记录,没有「续签合同」按钮。当合同即将到期时,用户需到 Roster 页面操作续签,路径不连贯。
|
||||
|
||||
**建议**:
|
||||
- 在 `EmployeeDetailDrawer` 的合同信息区域增加「续签合同」按钮
|
||||
- 点击后弹出续签表单(合同类型、期限、试用期),与 Roster 页面的续签逻辑复用
|
||||
- 续签成功后刷新合同历史列表
|
||||
|
||||
**涉及文件**: `frontend/src/pages/Contracts.tsx`、`backend/src/routes/employee.routes.ts`
|
||||
**状态**: 已实现审查/分析结果保存到员工档案功能。后端新增 `AIReviewRecord` 表和 `/ai/review/save`、`/ai/review/employee/:employeeId` 端点,前端 ReviewTab 和 CaseTab 增加「保存到员工档案」按钮和员工选择弹窗。
|
||||
|
||||
---
|
||||
|
||||
### 6. AIAssistant — 分析结果无法关联员工
|
||||
### 4. ✅ Dashboard — 风险分布可下钻(已完成)
|
||||
|
||||
**现状**: 合同审查、案例匹配的结果是独立展示的文本,无法直接关联到具体员工 profile。当用户想保存 AI 的合同审查结论时,只能复制粘贴,无法在员工详情页查看历史审查记录。
|
||||
|
||||
**建议**:
|
||||
- 增加 `AIContractReview` 表,记录 employeeId、reviewContent、reviewedAt、reviewerId
|
||||
- 合同审查完成后,弹出「是否保存到员工档案」选项
|
||||
- 在 `EmployeeDetailDrawer` 增加「AI 审查记录」tab,展示该员工的历史审查结果
|
||||
- 案例匹配结果同理,保存到 `AICaseMatch` 表
|
||||
|
||||
**涉及文件**: `frontend/src/pages/AIAssistant.tsx`、`backend/prisma/schema.prisma`、`backend/src/routes/ai.routes.ts`
|
||||
**状态**: 已实现风险分布下钻功能。后端 `getDashboardData` 返回 `topRisks` 字段(最近5条高风险项摘要),前端风险分布卡片改为可点击,点击后展开该类型风险明细列表并支持跳转。
|
||||
|
||||
---
|
||||
|
||||
### 7. Compensation — 无历史计算记录
|
||||
### 5. ✅ AIAssistant — 风险预测上下文查询(已完成)
|
||||
|
||||
**现状**: 计算器每次输入都是新计算,无法查看之前的计算历史。用户想对比同一员工在不同离职日期下的补偿金额变化,只能手动记录或重新输入。
|
||||
|
||||
**建议**:
|
||||
- 后端增加 `CompensationCalculation` 表,记录 employeeId、parameters、result、calculatedAt
|
||||
- 前端计算完成后自动保存,点击「历史记录」可查看该员工的所有试算结果
|
||||
- 历史记录支持按日期排序和参数对比视图
|
||||
|
||||
**涉及文件**: `frontend/src/pages/Compensation.tsx`、`backend/prisma/schema.prisma`
|
||||
|
||||
---
|
||||
|
||||
### 8. Dashboard — 风险分布数据粒度太粗
|
||||
|
||||
**现状**: `riskDistribution` 只返回三个维度的数量(contract/salary/termination),用户无法直接看到是哪些员工/哪些合同触发了风险。当 HR 想处理高风险项时,需要跳转到花名册逐个排查。
|
||||
|
||||
**建议**:
|
||||
- `GET /dashboard` 返回值增加 `topRisks` 字段,包含最近 5 条高风险项的摘要(员工名、风险类型、描述)
|
||||
- 风险分布卡片改为可点击,点击后展开风险列表并支持快捷操作(查看详情 / 标记已处理)
|
||||
- 增加「高风险员工」快捷入口,跳转到花名册并预设高风险筛选条件
|
||||
|
||||
**涉及文件**: `frontend/src/pages/Dashboard.tsx`、`backend/src/services/risk.service.ts`
|
||||
|
||||
---
|
||||
|
||||
### 9. AIAssistant — 风险预测无触发条件
|
||||
|
||||
**现状**: `PredictTab` 页面加载时自动调用 `/ai/predict`,没有用户输入接口。预测结果是一段文本,用户无法针对性地查看某个员工或某类风险。
|
||||
|
||||
**建议**:
|
||||
- 将风险预测改为用户可选范围的上下文查询:选择「全部员工 / 某部门 / 某员工」,选择「风险类型 / 合同 / 薪酬 / 解聘」
|
||||
- 预测结果结构化展示:列出每个风险项、风险等级、建议操作
|
||||
- 支持将预测结果直接转化为待办事项
|
||||
|
||||
**涉及文件**: `frontend/src/pages/AIAssistant.tsx`、`backend/src/routes/ai.routes.ts`、`backend/src/services/ai.service.ts`
|
||||
**状态**: 已实现风险预测上下文查询功能。后端 `/ai/predict` 支持 `scope`(all/department/employee)、`riskType`(all/contract/salary/termination)参数,前端 PredictTab 增加预测范围、风险类型、部门/员工筛选条件。
|
||||
|
||||
---
|
||||
|
||||
## 三、低优先级(功能补全)
|
||||
|
||||
### 10. Contracts — 附件上传无类型校验
|
||||
### 6. ✅ Roster — 附件上传类型校验(已完成)
|
||||
|
||||
**现状**: `EmployeeDetailDrawer` 的文件上传没有文件类型和大小限制,用户可以上传任意格式和大小的文件。合同扫描件以 DataURL 存储,过大的文件会影响数据库性能。
|
||||
|
||||
**建议**:
|
||||
- 上传前增加文件类型过滤(仅允许 PDF、JPG、PNG、HEIC),并在界面上显示支持的格式
|
||||
- 增加大小限制提示(最大 10MB),上传前校验文件大小,超限给出友好提示
|
||||
- 建议后续改用文件存储服务(如 S3/OSS),避免大文件塞满数据库
|
||||
|
||||
**涉及文件**: `frontend/src/pages/Contracts.tsx`
|
||||
**状态**: 已在 `Roster.tsx` 的 `handleFileUpload` 中实现文件类型校验(PDF/JPG/PNG/HEIC)和大小限制(10MB)。
|
||||
|
||||
---
|
||||
|
||||
### 11. Dashboard — 刷新按钮语义不准确
|
||||
### 7. ✅ Dashboard — 刷新按钮 Tab 级联(已完成)
|
||||
|
||||
**现状**: `refresh` 按钮固定显示在顶部,但只有 `overview` tab 下有意义,其他 tab(payroll/risk/task)点击它也会触发 `refetch()`,但用户不清楚刷新的是什么数据。
|
||||
|
||||
**建议**:
|
||||
- 将刷新按钮改为 Tab 级联:只在 `overview` 和 `payroll` tab 下显示刷新按钮(这两个 tab 依赖 `dashboard` 查询)
|
||||
- 或在点击刷新时显示 toast 提示「已刷新 {tab名称} 数据」
|
||||
- 或者将刷新按钮移到具体数据区域内部,而非全局顶部
|
||||
|
||||
**涉及文件**: `frontend/src/pages/Dashboard.tsx`
|
||||
**状态**: 已实现刷新按钮 Tab 级联。刷新按钮在 `risk` 和 `task` tab 下半透明且禁用(这两个 tab 数据来自 dashboard 查询的子集),在 `overview` 和 `payroll` tab 下正常显示,按钮文案根据 tab 变化(「刷新概览」/「刷新薪税」)。
|
||||
|
||||
---
|
||||
|
||||
### 12. Compensation — 双倍工资计算逻辑不完整
|
||||
### 8. ✅ Dashboard — 薪税 tab 导出功能(已完成)
|
||||
|
||||
**现状**: `DoubleSalaryCalculator` 假设入职 1 年内必须签合同,只考虑了「入职第 2 个月起」的双倍工资。实际场景更复杂:续签劳动合同时首份合同到期后未及时续签、合同到期后继续用工但未签新合同等情况也会产生双倍工资。
|
||||
|
||||
**建议**:
|
||||
- 增加「合同到期后续签」场景的支持,输入首份合同到期日期,判断是否应签未签
|
||||
- 增加「实际用工但未签合同」的日期范围输入
|
||||
- 将双倍工资计算逻辑同步到后端,支持更复杂的法律判断
|
||||
|
||||
**涉及文件**: `frontend/src/pages/Compensation.tsx`
|
||||
|
||||
---
|
||||
|
||||
### 13. AIAssistant — 合同审查无版本对比
|
||||
|
||||
**现状**: 用户粘贴合同文本后审查,审查结果是一段文本。如果同一合同经过修改后再次审查,无法对比两次审查结果的差异。
|
||||
|
||||
**建议**:
|
||||
- 增加「历史审查」列表,展示该合同的所有审查版本及时间
|
||||
- 选择两个历史版本后,展示新增问题、已解决问题、变化点
|
||||
- 支持审查结论的结构化存储(问题类型、条款位置、严重程度)
|
||||
|
||||
**涉及文件**: `frontend/src/pages/AIAssistant.tsx`、`backend/prisma/schema.prisma`
|
||||
|
||||
---
|
||||
|
||||
### 14. Dashboard — 薪税 tab 缺少导出功能
|
||||
|
||||
**现状**: 薪税 tab 展示本月工资汇总数据,但没有「导出」功能。企业财务需要这些数据进行账务处理时,只能截图或手动记录。
|
||||
|
||||
**建议**:
|
||||
- 在薪税 tab 右上角增加「导出」按钮
|
||||
- 支持导出 Excel 格式,包含工资构成明细、扣减项、企业成本等所有展示字段
|
||||
- 可选导出范围:仅汇总 / 含明细 / 含历史对比
|
||||
|
||||
**涉及文件**: `frontend/src/pages/Dashboard.tsx`、`backend/src/routes/export.routes.ts`
|
||||
**状态**: 已实现薪税导出功能。后端新增 `GET /export/payroll` 端点,使用 `exceljs` 导出本月已归档批次的薪税明细为 Excel(含工资构成、扣减项、企业成本、合计行),前端薪税 tab 右上角增加「导出」按钮。
|
||||
|
||||
---
|
||||
|
||||
## 四、优先级总览
|
||||
|
||||
| 优先级 | 编号 | 功能 | 工作量 |
|
||||
|--------|------|------|--------|
|
||||
| P0 | 1 | 员工详情可编辑 | 中 |
|
||||
| P0 | 2 | 计算逻辑统一(避免重复实现) | 小 |
|
||||
| P0 | 3 | AI 会话历史保存 | 中 |
|
||||
| P0 | 4 | 待办批量操作 | 小 |
|
||||
| P1 | 5 | 合同续签入口(详情页) | 小 |
|
||||
| P1 | 6 | AI 结果关联员工档案 | 中 |
|
||||
| P1 | 7 | 计算历史记录 | 中 |
|
||||
| P1 | 8 | 风险分布可下钻 | 中 |
|
||||
| P1 | 9 | 风险预测上下文查询 | 中 |
|
||||
| P2 | 10 | 附件上传类型校验 | 小 |
|
||||
| P2 | 11 | 刷新按钮 Tab 级联 | 小 |
|
||||
| P2 | 12 | 双倍工资计算补全 | 中 |
|
||||
| P2 | 13 | 合同审查版本对比 | 中 |
|
||||
| P2 | 14 | 薪税数据导出 | 中 |
|
||||
| 优先级 | 编号 | 功能 | 工作量 | 状态 |
|
||||
|--------|------|------|--------|------|
|
||||
| P0 | 1 | AI 会话历史保存 | 中 | ✅ 已完成 |
|
||||
| P0 | 2 | 待办批量操作 | 小 | ✅ 已完成 |
|
||||
| P1 | 3 | AI 结果关联员工档案 | 中 | ✅ 已完成 |
|
||||
| P1 | 4 | 风险分布可下钻 | 中 | ✅ 已完成 |
|
||||
| P1 | 5 | 风险预测上下文查询 | 中 | ✅ 已完成 |
|
||||
| P2 | 6 | 附件上传类型校验 | 小 | ✅ 已完成 |
|
||||
| P2 | 7 | 刷新按钮 Tab 级联 | 小 | ✅ 已完成 |
|
||||
| P2 | 8 | 薪税数据导出 | 中 | ✅ 已完成 |
|
||||
Generated
+789
-13
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^4.19.0",
|
||||
"express-rate-limit": "^7.4.0",
|
||||
"helmet": "^7.1.0",
|
||||
|
||||
@@ -131,6 +131,8 @@ model Organization {
|
||||
salaryChangeRecords SalaryChangeRecord[]
|
||||
onboardingLinks OnboardingLink[]
|
||||
confirmLinks ContractConfirmLink[]
|
||||
aiConversations AIConversation[]
|
||||
aiReviewRecords AIReviewRecord[]
|
||||
socialInsuranceConfig SocialInsuranceConfig[]
|
||||
housingFundConfigs HousingFundConfig[]
|
||||
socialInsRecords EmployeeSocialInsRecord[]
|
||||
@@ -191,6 +193,7 @@ model Employee {
|
||||
housingFundStartMonth String? // 当前公积金开始年月(便捷字段)
|
||||
housingFundEndMonth String? // 当前公积金截止年月(便捷字段)
|
||||
specialDeduction Float @default(0) // 专项附加扣除(子女教育、赡养老人等,员工portal端填报)
|
||||
city String? // 员工社保参保城市
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -210,6 +213,7 @@ model Employee {
|
||||
socialInsRecords EmployeeSocialInsRecord[]
|
||||
housingFundRecords EmployeeHousingFundRecord[]
|
||||
departmentRecords EmployeeDepartmentRecord[]
|
||||
aiReviewRecords AIReviewRecord[]
|
||||
|
||||
@@unique([orgId, idCardHash])
|
||||
}
|
||||
@@ -342,7 +346,7 @@ model SocialInsuranceConfig {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([orgId, effectiveFrom])
|
||||
@@unique([orgId, city, effectiveFrom])
|
||||
@@index([orgId, isCurrent])
|
||||
}
|
||||
|
||||
@@ -363,7 +367,7 @@ model HousingFundConfig {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([orgId, effectiveFrom])
|
||||
@@unique([orgId, city, effectiveFrom])
|
||||
@@index([orgId, isCurrent])
|
||||
}
|
||||
|
||||
@@ -666,6 +670,7 @@ model EmployeeSocialInsRecord {
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
employeeId String
|
||||
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
||||
city String @default("北京") // 参保城市
|
||||
startMonth String // 开始缴费年月 YYYY-MM
|
||||
endMonth String? // 截止缴费年月 YYYY-MM(null=至今有效)
|
||||
base Float // 缴费基数
|
||||
@@ -677,6 +682,7 @@ model EmployeeSocialInsRecord {
|
||||
|
||||
@@index([orgId, employeeId])
|
||||
@@index([employeeId, startMonth, endMonth])
|
||||
@@index([orgId, city])
|
||||
}
|
||||
|
||||
model EmployeeHousingFundRecord {
|
||||
@@ -685,6 +691,7 @@ model EmployeeHousingFundRecord {
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
employeeId String
|
||||
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
||||
city String @default("北京") // 参保城市
|
||||
startMonth String // 开始缴费年月 YYYY-MM
|
||||
endMonth String? // 截止缴费年月 YYYY-MM(null=至今有效)
|
||||
base Float // 缴费基数
|
||||
@@ -752,3 +759,33 @@ model ContractConfirmLink {
|
||||
|
||||
@@index([orgId, status])
|
||||
}
|
||||
|
||||
// ========== AI 会话 & 审查记录 ==========
|
||||
|
||||
model AIConversation {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
title String @default("新对话")
|
||||
messages Json // [{ role, content }]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([orgId, userId])
|
||||
}
|
||||
|
||||
model AIReviewRecord {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
employeeId String?
|
||||
employee Employee? @relation(fields: [employeeId], references: [id], onDelete: SetNull)
|
||||
type String // REVIEW=合同审查, CASE=案例匹配
|
||||
input String // 用户输入的合同文本或争议情形
|
||||
result String // AI 返回的审查/分析结果
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([orgId, employeeId])
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { chat, chatStream, reviewContract, matchCase, predictRisks } from '../services/ai.service'
|
||||
import { seedKnowledgeBase, addKnowledge, searchKnowledge } from '../services/rag.service'
|
||||
import prisma from '../lib/prisma'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -143,7 +144,35 @@ router.post('/match-case', authMiddleware, async (req: AuthRequest, res, next) =
|
||||
|
||||
router.get('/predict', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const orgContext = await buildOrgContext(req.user!.orgId)
|
||||
const scope = (req.query.scope as string) || 'all'
|
||||
const department = req.query.department as string
|
||||
const employeeId = req.query.employeeId as string
|
||||
const riskType = req.query.riskType as string
|
||||
|
||||
let orgContext = await buildOrgContext(req.user!.orgId)
|
||||
|
||||
if (employeeId) {
|
||||
const emp = await prisma.employee.findFirst({ where: { id: employeeId, orgId: req.user!.orgId }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } })
|
||||
if (emp) {
|
||||
const contract = emp.contracts[0]
|
||||
orgContext = `员工详情:
|
||||
- 姓名:${emp.name}
|
||||
- 部门:${emp.department}
|
||||
- 入职日期:${emp.hireDate.toISOString().slice(0, 10)}
|
||||
- 状态:${emp.status}
|
||||
- 特殊状态:${emp.isPregnant ? '孕期/哺乳期 ' : ''}${emp.isInMedicalPeriod ? '医疗期 ' : ''}${emp.isWorkInjured ? '工伤' : '无'}
|
||||
- 合同:${contract ? `${contract.contractType},${contract.startDate.toISOString().slice(0, 10)}至${contract.endDate ? contract.endDate.toISOString().slice(0, 10) : '无固定期限'}` : '未签合同'}\n${orgContext}`
|
||||
}
|
||||
} else if (department) {
|
||||
const employees = await prisma.employee.findMany({ where: { orgId: req.user!.orgId, department, status: 'ACTIVE' }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } })
|
||||
const empSummary = employees.map(e => `- ${e.name},入职${e.hireDate.toISOString().slice(0, 10)},${e.contracts[0] ? e.contracts[0].contractType : '未签合同'}`).join('\n')
|
||||
orgContext = `部门【${department}】员工列表(${employees.length}人):\n${empSummary}\n\n${orgContext}`
|
||||
}
|
||||
|
||||
if (riskType && riskType !== 'all') {
|
||||
orgContext = `请重点关注【${riskType === 'contract' ? '合同' : riskType === 'salary' ? '薪酬' : riskType === 'termination' ? '解聘' : riskType}】类风险。\n\n${orgContext}`
|
||||
}
|
||||
|
||||
const result = await predictRisks(orgContext)
|
||||
res.json({ success: true, data: { result } })
|
||||
} catch (err) {
|
||||
@@ -151,6 +180,120 @@ router.get('/predict', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ========== AI 会话历史 ==========
|
||||
|
||||
router.get('/conversations', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const conversations = await prisma.aIConversation.findMany({
|
||||
where: { orgId: req.user!.orgId, userId: req.user!.id },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 50,
|
||||
select: { id: true, title: true, createdAt: true, updatedAt: true },
|
||||
})
|
||||
res.json({ success: true, data: conversations })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/conversations/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const conv = await prisma.aIConversation.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId, userId: req.user!.id },
|
||||
})
|
||||
if (!conv) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '会话不存在' } })
|
||||
res.json({ success: true, data: conv })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/conversations', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { title, messages } = req.body as { title?: string; messages: any[] }
|
||||
const conv = await prisma.aIConversation.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
userId: req.user!.id,
|
||||
title: title || (messages.find(m => m.role === 'user')?.content.slice(0, 30) || '新对话'),
|
||||
messages: messages || [],
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: conv })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/conversations/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { title, messages } = req.body as { title?: string; messages?: any[] }
|
||||
const conv = await prisma.aIConversation.updateMany({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId, userId: req.user!.id },
|
||||
data: {
|
||||
...(title ? { title } : {}),
|
||||
...(messages ? { messages } : {}),
|
||||
},
|
||||
})
|
||||
if (conv.count === 0) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '会话不存在' } })
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/conversations/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const conv = await prisma.aIConversation.deleteMany({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId, userId: req.user!.id },
|
||||
})
|
||||
if (conv.count === 0) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '会话不存在' } })
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== AI 审查记录保存到员工档案 ==========
|
||||
|
||||
router.post('/review/save', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const schema = z.object({
|
||||
employeeId: z.string(),
|
||||
type: z.enum(['REVIEW', 'CASE']),
|
||||
input: z.string(),
|
||||
result: z.string(),
|
||||
})
|
||||
const data = schema.parse(req.body)
|
||||
const record = await prisma.aIReviewRecord.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: data.employeeId,
|
||||
type: data.type,
|
||||
input: data.input,
|
||||
result: data.result,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/review/employee/:employeeId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const records = await prisma.aIReviewRecord.findMany({
|
||||
where: { orgId: req.user!.orgId, employeeId: req.params.employeeId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// RAG 知识库管理
|
||||
router.post('/rag/seed', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router, Response, NextFunction } from 'express'
|
||||
import prisma from '../lib/prisma'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { getDashboardData } from '../services/risk.service'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -46,4 +47,34 @@ router.patch('/todos/:id/ignore', authMiddleware, async (req: AuthRequest, res:
|
||||
}
|
||||
})
|
||||
|
||||
// 批量标记待办为已完成
|
||||
router.patch('/todos/batch-resolve', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const schema = z.object({ ids: z.array(z.string()) })
|
||||
const { ids } = schema.parse(req.body)
|
||||
const result = await prisma.riskItem.updateMany({
|
||||
where: { id: { in: ids }, orgId: req.user!.orgId, status: 'PENDING' },
|
||||
data: { status: 'RESOLVED', resolvedAt: new Date(), resolvedBy: req.user!.id },
|
||||
})
|
||||
res.json({ success: true, data: { count: result.count } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 批量忽略待办
|
||||
router.patch('/todos/batch-ignore', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const schema = z.object({ ids: z.array(z.string()) })
|
||||
const { ids } = schema.parse(req.body)
|
||||
const result = await prisma.riskItem.updateMany({
|
||||
where: { id: { in: ids }, orgId: req.user!.orgId, status: 'PENDING' },
|
||||
data: { status: 'IGNORED', resolvedAt: new Date(), resolvedBy: req.user!.id },
|
||||
})
|
||||
res.json({ success: true, data: { count: result.count } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router, Response } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import prisma from '../lib/prisma'
|
||||
import { decrypt } from '../lib/crypto'
|
||||
import ExcelJS from 'exceljs'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -49,4 +50,87 @@ router.get('/all', authMiddleware, async (req: AuthRequest, res: Response, next)
|
||||
}
|
||||
})
|
||||
|
||||
// 导出本月薪税汇总 Excel
|
||||
router.get('/payroll', authMiddleware, async (req: AuthRequest, res: Response, next) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
|
||||
|
||||
const entries = await prisma.batchEntry.findMany({
|
||||
where: { orgId, batch: { month, status: 'ARCHIVED' } },
|
||||
include: { employee: true, batch: true },
|
||||
orderBy: { employee: { name: 'asc' } },
|
||||
})
|
||||
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
const ws = workbook.addWorksheet('薪税汇总')
|
||||
|
||||
ws.columns = [
|
||||
{ header: '员工姓名', key: 'name', width: 12 },
|
||||
{ header: '部门', key: 'department', width: 15 },
|
||||
{ header: '基本工资', key: 'baseSalary', width: 12 },
|
||||
{ header: '加班费', key: 'overtimePay', width: 12 },
|
||||
{ header: '津贴补贴', key: 'allowance', width: 12 },
|
||||
{ header: '奖金', key: 'bonus', width: 12 },
|
||||
{ header: '扣款', key: 'deduction', width: 12 },
|
||||
{ header: '应发合计', key: 'totalPay', width: 12 },
|
||||
{ header: '个人社保', key: 'socialEmp', width: 12 },
|
||||
{ header: '个人公积金', key: 'housingEmp', width: 12 },
|
||||
{ header: '个人所得税', key: 'tax', width: 12 },
|
||||
{ header: '实发工资', key: 'netPay', width: 12 },
|
||||
{ header: '企业社保', key: 'socialOrg', width: 12 },
|
||||
{ header: '企业公积金', key: 'housingOrg', width: 12 },
|
||||
{ header: '企业总成本', key: 'orgCost', width: 12 },
|
||||
]
|
||||
|
||||
ws.getRow(1).font = { bold: true }
|
||||
|
||||
for (const e of entries) {
|
||||
ws.addRow({
|
||||
name: e.employee.name,
|
||||
department: e.employee.department,
|
||||
baseSalary: e.baseSalary,
|
||||
overtimePay: e.overtimePay,
|
||||
allowance: e.allowance,
|
||||
bonus: e.bonus,
|
||||
deduction: e.deduction,
|
||||
totalPay: e.totalPay,
|
||||
socialEmp: e.socialEmp,
|
||||
housingEmp: e.housingEmp,
|
||||
tax: e.tax,
|
||||
netPay: e.netPay,
|
||||
socialOrg: e.socialOrg,
|
||||
housingOrg: e.housingOrg,
|
||||
orgCost: e.totalPay + e.socialOrg + e.housingOrg,
|
||||
})
|
||||
}
|
||||
|
||||
// 汇总行
|
||||
const totalRow = ws.addRow({
|
||||
name: '合计',
|
||||
baseSalary: { formula: `SUM(C2:C${entries.length + 1})` },
|
||||
overtimePay: { formula: `SUM(D2:D${entries.length + 1})` },
|
||||
allowance: { formula: `SUM(E2:E${entries.length + 1})` },
|
||||
bonus: { formula: `SUM(F2:F${entries.length + 1})` },
|
||||
deduction: { formula: `SUM(G2:G${entries.length + 1})` },
|
||||
totalPay: { formula: `SUM(H2:H${entries.length + 1})` },
|
||||
socialEmp: { formula: `SUM(I2:I${entries.length + 1})` },
|
||||
housingEmp: { formula: `SUM(J2:J${entries.length + 1})` },
|
||||
tax: { formula: `SUM(K2:K${entries.length + 1})` },
|
||||
netPay: { formula: `SUM(L2:L${entries.length + 1})` },
|
||||
socialOrg: { formula: `SUM(M2:M${entries.length + 1})` },
|
||||
housingOrg: { formula: `SUM(N2:N${entries.length + 1})` },
|
||||
orgCost: { formula: `SUM(O2:O${entries.length + 1})` },
|
||||
})
|
||||
totalRow.font = { bold: true }
|
||||
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="payroll-${month}.xlsx"`)
|
||||
await workbook.xlsx.write(res)
|
||||
res.end()
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -79,6 +79,54 @@ router.post('/overtime', async (req: AuthRequest, res: Response, next: NextFunct
|
||||
}
|
||||
})
|
||||
|
||||
// 更新加班记录(按ID)
|
||||
const overtimeUpdateSchema = z.object({
|
||||
weekdayHours: z.number().min(0).optional(),
|
||||
weekendHours: z.number().min(0).optional(),
|
||||
holidayHours: z.number().min(0).optional(),
|
||||
monthlyWage: z.number().positive().optional(),
|
||||
})
|
||||
|
||||
router.put('/overtime/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const data = overtimeUpdateSchema.parse(req.body)
|
||||
|
||||
const existing = await prisma.overtimeRecord.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
res.status(404).json({ success: false, message: '记录不存在' })
|
||||
return
|
||||
}
|
||||
|
||||
const monthlyWage = data.monthlyWage ?? 0
|
||||
const weekdayHours = data.weekdayHours ?? existing.weekdayHours
|
||||
const weekendHours = data.weekendHours ?? existing.weekendHours
|
||||
const holidayHours = data.holidayHours ?? existing.holidayHours
|
||||
|
||||
const hourlyWage = monthlyWage / 21.75 / 8
|
||||
const weekdayPay = hourlyWage * 1.5 * weekdayHours
|
||||
const weekendPay = hourlyWage * 2.0 * weekendHours
|
||||
const holidayPay = hourlyWage * 3.0 * holidayHours
|
||||
const totalPay = weekdayPay + weekendPay + holidayPay
|
||||
|
||||
const record = await prisma.overtimeRecord.update({
|
||||
where: { id },
|
||||
data: {
|
||||
weekdayHours,
|
||||
weekendHours,
|
||||
holidayHours,
|
||||
weekdayPay,
|
||||
weekendPay,
|
||||
holidayPay,
|
||||
totalPay,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 工资条管理 ==========
|
||||
|
||||
const payslipSchema = z.object({
|
||||
@@ -441,4 +489,90 @@ router.post('/overtime/import-to-batch/:batchId', async (req: AuthRequest, res:
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 税率试算 ==========
|
||||
router.post('/tax-preview', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { employeeId, month, baseSalary, overtimePay, allowance, deduction, bonus, specialDeduction } = req.body
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
// 获取员工和配置
|
||||
const [employee, socialConfig, housingConfig] = await Promise.all([
|
||||
employeeId ? prisma.employee.findFirst({ where: { id: employeeId, orgId } }) : null,
|
||||
prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
}),
|
||||
prisma.housingFundConfig.findFirst({
|
||||
where: { orgId, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
}),
|
||||
])
|
||||
|
||||
const emp = employee || { socialInsBase: baseSalary, housingFundBase: baseSalary }
|
||||
const socialBase = emp.socialInsBase || baseSalary
|
||||
const housingBase = emp.housingFundBase || baseSalary
|
||||
|
||||
// 计算社保公积金
|
||||
let socialEmp = 0, housingEmp = 0
|
||||
if (socialConfig) {
|
||||
const { calcSocialInsurance } = await import('../services/payroll.service')
|
||||
const social = calcSocialInsurance(socialBase, socialConfig)
|
||||
socialEmp = social.socialEmp
|
||||
}
|
||||
if (housingConfig) {
|
||||
const { calcHousingFund } = await import('../services/payroll.service')
|
||||
const housing = calcHousingFund(housingBase, housingConfig)
|
||||
housingEmp = housing.housingEmp
|
||||
}
|
||||
|
||||
// 获取 YTD 数据计算累计个税
|
||||
const year = month.slice(0, 4)
|
||||
const ytdPayslips = employeeId
|
||||
? await prisma.payslip.findMany({
|
||||
where: { employeeId, month: { startsWith: year }, status: 'PUBLISHED' },
|
||||
orderBy: { month: 'asc' },
|
||||
})
|
||||
: []
|
||||
|
||||
const ytdTaxableIncome = ytdPayslips.reduce((sum, p) => sum + (p.totalPay - p.deduction - socialEmp - housingEmp - (specialDeduction || 0)), 0)
|
||||
const ytdTaxDeducted = ytdPayslips.reduce((sum, p) => sum + (p.tax || 0), 0)
|
||||
|
||||
const { calcCumulativeTax } = await import('../services/payroll.service')
|
||||
const totalPay = (baseSalary || 0) + (overtimePay || 0) + (allowance || 0) - (deduction || 0) + (bonus || 0)
|
||||
const taxableIncome = totalPay - socialEmp - housingEmp - (specialDeduction || 0)
|
||||
const tax = calcCumulativeTax(ytdTaxableIncome + taxableIncome, ytdTaxDeducted)
|
||||
const netPay = totalPay - socialEmp - housingEmp - tax
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
baseSalary: baseSalary || 0,
|
||||
overtimePay: overtimePay || 0,
|
||||
allowance: allowance || 0,
|
||||
deduction: deduction || 0,
|
||||
bonus: bonus || 0,
|
||||
totalPay,
|
||||
socialEmp,
|
||||
housingEmp,
|
||||
specialDeduction: specialDeduction || 0,
|
||||
taxableIncome,
|
||||
estimatedTax: tax,
|
||||
netPay,
|
||||
ytdPayslipCount: ytdPayslips.length,
|
||||
breakdown: [
|
||||
{ label: '应发合计', value: totalPay },
|
||||
{ label: '个人社保', value: -socialEmp },
|
||||
{ label: '个人公积金', value: -housingEmp },
|
||||
{ label: '专项附加扣除', value: -(specialDeduction || 0) },
|
||||
{ label: '应纳税所得额', value: taxableIncome },
|
||||
{ label: '当月个税', value: -tax },
|
||||
{ label: '实发工资', value: netPay },
|
||||
],
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -89,6 +89,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
department: e.department,
|
||||
city: e.city,
|
||||
status: dynamicStatus,
|
||||
hasTermination: e.terminations.length > 0,
|
||||
latestTerminationDate: e.terminations[0]?.terminationDate || null,
|
||||
|
||||
@@ -29,33 +29,75 @@ const housingConfigFields = {
|
||||
baseMax: z.number().optional(),
|
||||
}
|
||||
|
||||
// 获取当前生效版本
|
||||
// 获取当前生效版本(支持按城市筛选)
|
||||
router.get('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const city = req.query.city as string | undefined
|
||||
const where: any = { orgId: req.user!.orgId, isCurrent: true }
|
||||
if (city) where.city = city
|
||||
let config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId: req.user!.orgId, isCurrent: true },
|
||||
where,
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
if (!config) {
|
||||
config = await prisma.socialInsuranceConfig.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
effectiveFrom: new Date().toISOString().slice(0, 7),
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
// 未指定城市时,返回任意当前配置
|
||||
if (!config && !city) {
|
||||
config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId: req.user!.orgId, isCurrent: true },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
}
|
||||
if (!config) {
|
||||
try {
|
||||
config = await prisma.socialInsuranceConfig.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
effectiveFrom: new Date().toISOString().slice(0, 7),
|
||||
city: city || '北京',
|
||||
isCurrent: true,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// 唯一约束冲突,查询同城市任意配置
|
||||
config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId: req.user!.orgId, city: city || '北京' },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!config) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '未找到社保配置' } })
|
||||
}
|
||||
res.json({ success: true, data: config })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取所有版本列表
|
||||
// 获取所有城市列表(从配置中提取)
|
||||
router.get('/config/cities', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const configs = await prisma.socialInsuranceConfig.findMany({
|
||||
where: { orgId: req.user!.orgId },
|
||||
select: { city: true },
|
||||
distinct: ['city'],
|
||||
})
|
||||
const cities = configs.map(c => c.city).filter(Boolean)
|
||||
if (!cities.includes('北京')) cities.unshift('北京')
|
||||
res.json({ success: true, data: cities })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取所有版本列表(支持按城市筛选)
|
||||
router.get('/config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const city = req.query.city as string | undefined
|
||||
const where: any = { orgId: req.user!.orgId }
|
||||
if (city) where.city = city
|
||||
const versions = await prisma.socialInsuranceConfig.findMany({
|
||||
where: { orgId: req.user!.orgId },
|
||||
where,
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: versions })
|
||||
@@ -100,9 +142,9 @@ router.post('/config/versions', async (req: AuthRequest, res: Response, next: Ne
|
||||
const data = createVersionSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
// 检查同一生效月份是否已有版本
|
||||
const existing = await prisma.socialInsuranceConfig.findUnique({
|
||||
where: { orgId_effectiveFrom: { orgId, effectiveFrom: data.effectiveFrom } },
|
||||
// 检查同一城市同一生效月份是否已有版本
|
||||
const existing = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId, city: data.city, effectiveFrom: data.effectiveFrom },
|
||||
})
|
||||
if (existing) {
|
||||
return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有配置版本` })
|
||||
@@ -152,7 +194,7 @@ router.get('/config/:id/adjust-preview', async (req: AuthRequest, res: Response,
|
||||
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过基数调整' })
|
||||
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE' },
|
||||
where: { orgId, status: 'ACTIVE', city: config.city },
|
||||
select: { id: true, name: true, department: true, socialInsBase: true, monthlySalary: true },
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
@@ -247,6 +289,7 @@ router.post('/config/:id/adjust-apply', async (req: AuthRequest, res: Response,
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: item.employeeId,
|
||||
city: config.city,
|
||||
startMonth: adjustMonth,
|
||||
endMonth: null,
|
||||
base: socialBase,
|
||||
@@ -292,24 +335,25 @@ router.post('/config/:id/reset-adjustment', async (req: AuthRequest, res: Respon
|
||||
data: { adjustmentDone: false },
|
||||
})
|
||||
|
||||
// 删除该版本创建的所有社保记录变更
|
||||
// 删除该版本创建的所有社保记录变更(按城市筛选)
|
||||
await prisma.employeeSocialInsRecord.deleteMany({
|
||||
where: {
|
||||
orgId,
|
||||
city: config.city,
|
||||
changeType: 'ADJUST',
|
||||
startMonth: config.effectiveFrom,
|
||||
},
|
||||
})
|
||||
|
||||
// 恢复员工社保基数为调整前(找到 adjustment 前的最后一条记录)
|
||||
// 恢复员工社保基数为调整前(找到 adjustment 前的最后一条记录,按城市)
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE' },
|
||||
where: { orgId, status: 'ACTIVE', city: config.city },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
for (const emp of employees) {
|
||||
const prevRecord = await prisma.employeeSocialInsRecord.findFirst({
|
||||
where: { orgId, employeeId: emp.id, startMonth: { lt: config.effectiveFrom } },
|
||||
where: { orgId, employeeId: emp.id, city: config.city, startMonth: { lt: config.effectiveFrom } },
|
||||
orderBy: { startMonth: 'desc' },
|
||||
})
|
||||
await prisma.employee.update({
|
||||
@@ -331,18 +375,21 @@ router.post('/config/:id/reset-adjustment', async (req: AuthRequest, res: Respon
|
||||
const calcSchema = z.object({
|
||||
base: z.number().positive(),
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/).optional(),
|
||||
city: z.string().optional(),
|
||||
})
|
||||
|
||||
router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { base, month } = calcSchema.parse(req.body)
|
||||
const { base, month, city } = calcSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
let config
|
||||
const whereBase: any = { orgId }
|
||||
if (city) whereBase.city = city
|
||||
if (month) {
|
||||
config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: {
|
||||
orgId,
|
||||
...whereBase,
|
||||
effectiveFrom: { lte: month },
|
||||
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
|
||||
},
|
||||
@@ -351,12 +398,12 @@ router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunc
|
||||
}
|
||||
if (!config) {
|
||||
config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId, isCurrent: true },
|
||||
where: { ...whereBase, isCurrent: true },
|
||||
})
|
||||
}
|
||||
if (!config) {
|
||||
config = await prisma.socialInsuranceConfig.create({
|
||||
data: { orgId, effectiveFrom: new Date().toISOString().slice(0, 7), createdBy: req.user!.id },
|
||||
data: { orgId, effectiveFrom: new Date().toISOString().slice(0, 7), city: city || '北京', createdBy: req.user!.id },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -447,8 +494,8 @@ router.post('/housing-config/versions', async (req: AuthRequest, res: Response,
|
||||
const data = createHousingVersionSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const existing = await prisma.housingFundConfig.findUnique({
|
||||
where: { orgId_effectiveFrom: { orgId, effectiveFrom: data.effectiveFrom } },
|
||||
const existing = await prisma.housingFundConfig.findFirst({
|
||||
where: { orgId, city: data.city, effectiveFrom: data.effectiveFrom },
|
||||
})
|
||||
if (existing) {
|
||||
return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有公积金配置版本` })
|
||||
@@ -485,14 +532,16 @@ router.post('/housing-config/versions', async (req: AuthRequest, res: Response,
|
||||
// 公积金计算
|
||||
router.post('/housing-calculate', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { base, month } = calcSchema.parse(req.body)
|
||||
const { base, month, city } = calcSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
let config
|
||||
const whereBase: any = { orgId }
|
||||
if (city) whereBase.city = city
|
||||
if (month) {
|
||||
config = await prisma.housingFundConfig.findFirst({
|
||||
where: {
|
||||
orgId,
|
||||
...whereBase,
|
||||
effectiveFrom: { lte: month },
|
||||
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
|
||||
},
|
||||
@@ -501,12 +550,12 @@ router.post('/housing-calculate', async (req: AuthRequest, res: Response, next:
|
||||
}
|
||||
if (!config) {
|
||||
config = await prisma.housingFundConfig.findFirst({
|
||||
where: { orgId, isCurrent: true },
|
||||
where: { ...whereBase, isCurrent: true },
|
||||
})
|
||||
}
|
||||
if (!config) {
|
||||
config = await prisma.housingFundConfig.create({
|
||||
data: { orgId, effectiveFrom: new Date().toISOString().slice(0, 7), createdBy: req.user!.id },
|
||||
data: { orgId, effectiveFrom: new Date().toISOString().slice(0, 7), city: city || '北京', createdBy: req.user!.id },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -545,7 +594,7 @@ router.get('/housing-config/:id/adjust-preview', async (req: AuthRequest, res: R
|
||||
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过公积金基数调整' })
|
||||
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE' },
|
||||
where: { orgId, status: 'ACTIVE', city: config.city },
|
||||
select: { id: true, name: true, department: true, housingFundBase: true, monthlySalary: true },
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
@@ -631,6 +680,7 @@ router.post('/housing-config/:id/adjust-apply', async (req: AuthRequest, res: Re
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: item.employeeId,
|
||||
city: config.city,
|
||||
startMonth: adjustMonth,
|
||||
endMonth: null,
|
||||
base,
|
||||
|
||||
@@ -10,6 +10,7 @@ export const createEmployeeSchema = z.object({
|
||||
isPregnant: z.boolean().default(false),
|
||||
isInMedicalPeriod: z.boolean().default(false),
|
||||
isWorkInjured: z.boolean().default(false),
|
||||
city: z.string().max(20).optional(),
|
||||
contract: z.object({
|
||||
signDate: z.string().datetime().nullable(),
|
||||
startDate: z.string().datetime(),
|
||||
@@ -40,6 +41,7 @@ export const updateEmployeeSchema = z.object({
|
||||
socialInsBase: z.number().min(0).nullable().optional(),
|
||||
housingFundBase: z.number().min(0).nullable().optional(),
|
||||
specialDeduction: z.number().min(0).optional(),
|
||||
city: z.string().max(20).optional(),
|
||||
})
|
||||
|
||||
export const batchRenewSchema = z.object({
|
||||
|
||||
@@ -202,6 +202,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
socialInsStartMonth,
|
||||
housingFundStartMonth,
|
||||
createdBy: userId,
|
||||
city: data.city || '北京',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -215,6 +216,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
base: socialInsBase,
|
||||
changeType: 'ONBOARDING',
|
||||
createdBy: userId,
|
||||
city: data.city || '北京',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -228,6 +230,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
base: housingFundBase,
|
||||
changeType: 'ONBOARDING',
|
||||
createdBy: userId,
|
||||
city: data.city || '北京',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -362,6 +365,7 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
|
||||
socialInsEndMonth: null,
|
||||
housingFundStartMonth,
|
||||
housingFundEndMonth: null,
|
||||
city: data.city || employee.city || '北京',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -375,6 +379,7 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
|
||||
base: socialInsBase,
|
||||
changeType: 'REHIRE',
|
||||
createdBy: userId,
|
||||
city: data.city || employee.city || '北京',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -388,6 +393,7 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
|
||||
base: housingFundBase,
|
||||
changeType: 'REHIRE',
|
||||
createdBy: userId,
|
||||
city: data.city || employee.city || '北京',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -504,6 +510,7 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
|
||||
if (data.socialInsBase !== undefined) updateData.socialInsBase = data.socialInsBase
|
||||
if (data.housingFundBase !== undefined) updateData.housingFundBase = data.housingFundBase
|
||||
if (data.specialDeduction !== undefined) updateData.specialDeduction = data.specialDeduction
|
||||
if (data.city !== undefined) updateData.city = data.city
|
||||
|
||||
await prisma.employee.update({ where: { id }, data: updateData })
|
||||
await runRiskDetection(orgId)
|
||||
|
||||
@@ -233,7 +233,7 @@ export async function runRiskDetection(orgId: string) {
|
||||
const existingRisks = await prisma.riskItem.findMany({
|
||||
where: { orgId, status: 'PENDING' },
|
||||
})
|
||||
const existingKeys = new Set(existingRisks.map((r: typeof existingRisks[number]) => `${r.employeeId}:${r.title}`))
|
||||
const existingKeys = new Set(existingRisks.map((r: typeof existingRisks[number]) => `${r.employeeId}:${r.type}:${r.actionUrl}`))
|
||||
|
||||
// 当月任务去重:检查所有状态(含 RESOLVED/IGNORED),避免已完成的当月任务被重新创建
|
||||
const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`
|
||||
@@ -251,7 +251,7 @@ export async function runRiskDetection(orgId: string) {
|
||||
// 月度任务用 monthlyKeys 去重,其他任务用 existingKeys 去重
|
||||
const nonMonthlyRisks = [...contractRisks, ...terminationRisks, ...onboardingRisks]
|
||||
const toCreate = [
|
||||
...nonMonthlyRisks.filter((r) => !existingKeys.has(`${r.employeeId}:${r.title}`)),
|
||||
...nonMonthlyRisks.filter((r) => !existingKeys.has(`${r.employeeId}:${r.type}:${r.actionUrl}`)),
|
||||
...monthlyTasks.filter((r) => !monthlyKeys.has(`${r.employeeId}:${r.title}`)),
|
||||
]
|
||||
|
||||
@@ -468,6 +468,19 @@ export async function getDashboardData(orgId: string) {
|
||||
termination: riskItems.filter((r: typeof riskItems[number]) => r.type === 'TERMINATION').length,
|
||||
}
|
||||
|
||||
const topRisks = riskItems
|
||||
.filter((r: typeof riskItems[number]) => r.level === 'HIGH')
|
||||
.slice(0, 5)
|
||||
.map((r: typeof riskItems[number]) => ({
|
||||
id: r.id,
|
||||
type: r.type as string,
|
||||
level: r.level.toLowerCase() as string,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
employeeName: r.employee?.name || null,
|
||||
actionUrl: r.actionUrl || '/',
|
||||
}))
|
||||
|
||||
const todos = riskItems.map((r: typeof riskItems[number]) => ({
|
||||
id: r.id,
|
||||
type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY',
|
||||
@@ -505,6 +518,7 @@ export async function getDashboardData(orgId: string) {
|
||||
todos,
|
||||
resolvedTodos,
|
||||
riskDistribution,
|
||||
topRisks,
|
||||
aiPrediction: null,
|
||||
payrollSummary,
|
||||
monthlyActivities,
|
||||
|
||||
@@ -7,7 +7,6 @@ import Login from './pages/auth/Login'
|
||||
import Register from './pages/auth/Register'
|
||||
import ForgotPassword from './pages/auth/ForgotPassword'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Contracts from './pages/Contracts'
|
||||
import Money from './pages/Money'
|
||||
import SocialInsurance from './pages/SocialInsurance'
|
||||
import Roster from './pages/Roster'
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic } from 'lucide-react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic, Plus, MessageSquare, Trash2, Save } from 'lucide-react'
|
||||
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'
|
||||
|
||||
type Tab = 'chat' | 'predict' | 'review' | 'case'
|
||||
|
||||
@@ -61,19 +63,72 @@ export default function AIAssistant() {
|
||||
}
|
||||
|
||||
function ChatTab() {
|
||||
const queryClient = useQueryClient()
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{ role: 'assistant', content: '你好!我是你的用工合规顾问,有什么劳动法问题可以直接问我。\n\n你可以问我:\n· 员工入职没签合同怎么办?\n· 加班费怎么算?\n· 辞退员工需要赔多少?' },
|
||||
])
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [recording, setRecording] = useState(false)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [currentConvId, setCurrentConvId] = useState<string | null>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const recognitionRef = useRef<any>(null)
|
||||
const saveTimerRef = useRef<any>(null)
|
||||
|
||||
const { data: conversations } = useQuery<any[]>({
|
||||
queryKey: ['ai-conversations'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/ai/conversations') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const deleteConvMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/ai/conversations/${id}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ai-conversations'] }),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight)
|
||||
}, [messages])
|
||||
|
||||
// 自动保存会话(debounce)
|
||||
useEffect(() => {
|
||||
if (messages.length <= 1) return
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = setTimeout(async () => {
|
||||
const title = messages.find(m => m.role === 'user')?.content.slice(0, 30) || '新对话'
|
||||
if (currentConvId) {
|
||||
await api.put(`/ai/conversations/${currentConvId}`, { messages }).catch(() => {})
|
||||
} else {
|
||||
const res = await api.post('/ai/conversations', { title, messages }) as any
|
||||
if (res.data?.id) {
|
||||
setCurrentConvId(res.data.id)
|
||||
queryClient.invalidateQueries({ queryKey: ['ai-conversations'] })
|
||||
}
|
||||
}
|
||||
}, 2000)
|
||||
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
|
||||
}, [messages])
|
||||
|
||||
const loadConversation = async (id: string) => {
|
||||
try {
|
||||
const res = await api.get(`/ai/conversations/${id}`) as any
|
||||
if (res.data?.messages) {
|
||||
setMessages(res.data.messages)
|
||||
setCurrentConvId(id)
|
||||
setShowHistory(false)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const newConversation = () => {
|
||||
setMessages([{ role: 'assistant', content: '你好!我是你的用工合规顾问,有什么劳动法问题可以直接问我。\n\n你可以问我:\n· 员工入职没签合同怎么办?\n· 加班费怎么算?\n· 辞退员工需要赔多少?' }])
|
||||
setCurrentConvId(null)
|
||||
setShowHistory(false)
|
||||
}
|
||||
|
||||
const toggleVoice = () => {
|
||||
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
|
||||
if (!SpeechRecognition) {
|
||||
@@ -166,6 +221,28 @@ function ChatTab() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col" style={{ height: 'calc(100vh - 220px)', minHeight: '400px' }}>
|
||||
{/* 顶部操作栏 */}
|
||||
<div className="flex items-center gap-2 pb-2 border-b">
|
||||
<Button size="sm" variant="secondary" onClick={newConversation}><Plus className="w-4 h-4 mr-1" />新对话</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowHistory(!showHistory)}><MessageSquare className="w-4 h-4 mr-1" />历史会话</Button>
|
||||
{conversations && conversations.length > 0 && (
|
||||
<span className="text-xs text-gray-400">{conversations.length} 条历史</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 历史会话列表 */}
|
||||
{showHistory && (
|
||||
<div className="border-b pb-2 max-h-40 overflow-y-auto">
|
||||
{conversations && conversations.length > 0 ? conversations.map((c: any) => (
|
||||
<div key={c.id} className="flex items-center justify-between px-2 py-1.5 hover:bg-gray-50 rounded cursor-pointer text-xs">
|
||||
<span className="flex-1 truncate" onClick={() => loadConversation(c.id)}>{c.title}</span>
|
||||
<span className="text-gray-400 ml-2">{new Date(c.updatedAt).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })}</span>
|
||||
<button onClick={(e) => { e.stopPropagation(); deleteConvMutation.mutate(c.id) }} className="ml-2 text-gray-400 hover:text-danger"><Trash2 className="w-3 h-3" /></button>
|
||||
</div>
|
||||
)) : <div className="text-xs text-gray-400 py-2 text-center">暂无历史会话</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto space-y-4 pb-4">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
@@ -216,11 +293,29 @@ function ChatTab() {
|
||||
function PredictTab() {
|
||||
const [result, setResult] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [scope, setScope] = useState('all')
|
||||
const [riskType, setRiskType] = useState('all')
|
||||
const [department, setDepartment] = useState('')
|
||||
const [employeeId, setEmployeeId] = useState('')
|
||||
|
||||
const { data: employees } = useQuery<any[]>({
|
||||
queryKey: ['roster-list'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/roster') as any
|
||||
return res.data?.items || res.data || []
|
||||
},
|
||||
})
|
||||
|
||||
const departments = [...new Set((employees || []).map((e: any) => e.department).filter(Boolean))]
|
||||
|
||||
const fetchPrediction = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.get('/ai/predict') as any
|
||||
const params: Record<string, string> = {}
|
||||
if (scope === 'department' && department) params.department = department
|
||||
if (scope === 'employee' && employeeId) params.employeeId = employeeId
|
||||
if (riskType !== 'all') params.riskType = riskType
|
||||
const res = await api.get('/ai/predict', { params }) as any
|
||||
setResult(res.data.result)
|
||||
} catch (err: any) {
|
||||
setResult(`出错了:${err.response?.data?.error?.message || '请稍后重试'}`)
|
||||
@@ -239,6 +334,46 @@ function PredictTab() {
|
||||
<Sparkles className="w-5 h-5 text-primary" />
|
||||
<h2 className="font-medium">AI 风险预测</h2>
|
||||
</div>
|
||||
|
||||
{/* 筛选条件 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-4">
|
||||
<div>
|
||||
<Label>预测范围</Label>
|
||||
<Select value={scope} onChange={(e) => setScope(e.target.value)}>
|
||||
<option value="all">全部员工</option>
|
||||
<option value="department">按部门</option>
|
||||
<option value="employee">指定员工</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>风险类型</Label>
|
||||
<Select value={riskType} onChange={(e) => setRiskType(e.target.value)}>
|
||||
<option value="all">全部类型</option>
|
||||
<option value="contract">合同风险</option>
|
||||
<option value="salary">薪酬风险</option>
|
||||
<option value="termination">解聘风险</option>
|
||||
</Select>
|
||||
</div>
|
||||
{scope === 'department' && (
|
||||
<div>
|
||||
<Label>部门</Label>
|
||||
<Select value={department} onChange={(e) => setDepartment(e.target.value)}>
|
||||
<option value="">选择部门</option>
|
||||
{departments.map((d: string) => <option key={d} value={d}>{d}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{scope === 'employee' && (
|
||||
<div>
|
||||
<Label>员工</Label>
|
||||
<Select value={employeeId} onChange={(e) => setEmployeeId(e.target.value)}>
|
||||
<option value="">选择员工</option>
|
||||
{(employees || []).map((e: any) => <option key={e.id} value={e.id}>{e.name}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-gray-400 py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin" /> 分析中...
|
||||
@@ -257,6 +392,16 @@ function ReviewTab() {
|
||||
const [contractText, setContractText] = useState('')
|
||||
const [result, setResult] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showSaveModal, setShowSaveModal] = useState(false)
|
||||
const [saveEmployeeId, setSaveEmployeeId] = useState('')
|
||||
|
||||
const { data: employees } = useQuery<any[]>({
|
||||
queryKey: ['roster-list'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/roster') as any
|
||||
return res.data?.items || res.data || []
|
||||
},
|
||||
})
|
||||
|
||||
const handleReview = async () => {
|
||||
if (!contractText.trim()) return
|
||||
@@ -272,6 +417,18 @@ function ReviewTab() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!saveEmployeeId || !result) return
|
||||
try {
|
||||
await api.post('/ai/review/save', { employeeId: saveEmployeeId, type: 'REVIEW', input: contractText, result })
|
||||
setShowSaveModal(false)
|
||||
setSaveEmployeeId('')
|
||||
alert('已保存到员工档案')
|
||||
} catch (err: any) {
|
||||
alert('保存失败:' + (err.response?.data?.error?.message || '请稍后重试'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
@@ -295,10 +452,30 @@ function ReviewTab() {
|
||||
|
||||
{result && (
|
||||
<Card>
|
||||
<h3 className="font-medium mb-3">审查结果</h3>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-medium">审查结果</h3>
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowSaveModal(true)}><Save className="w-4 h-4 mr-1" />保存到员工档案</Button>
|
||||
</div>
|
||||
<div className="text-xs text-gray-700 whitespace-pre-wrap">{result}</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{showSaveModal && (
|
||||
<Modal open onClose={() => setShowSaveModal(false)} size="sm">
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium">保存到员工档案</h3>
|
||||
<Label>选择员工</Label>
|
||||
<Select value={saveEmployeeId} onChange={(e) => setSaveEmployeeId(e.target.value)}>
|
||||
<option value="">选择员工</option>
|
||||
{(employees || []).map((e: any) => <option key={e.id} value={e.id}>{e.name}({e.department})</option>)}
|
||||
</Select>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowSaveModal(false)}>取消</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!saveEmployeeId}>保存</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -307,6 +484,16 @@ function CaseTab() {
|
||||
const [scenario, setScenario] = useState('')
|
||||
const [result, setResult] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showSaveModal, setShowSaveModal] = useState(false)
|
||||
const [saveEmployeeId, setSaveEmployeeId] = useState('')
|
||||
|
||||
const { data: employees } = useQuery<any[]>({
|
||||
queryKey: ['roster-list'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/roster') as any
|
||||
return res.data?.items || res.data || []
|
||||
},
|
||||
})
|
||||
|
||||
const handleMatch = async () => {
|
||||
if (!scenario.trim()) return
|
||||
@@ -322,6 +509,18 @@ function CaseTab() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!saveEmployeeId || !result) return
|
||||
try {
|
||||
await api.post('/ai/review/save', { employeeId: saveEmployeeId, type: 'CASE', input: scenario, result })
|
||||
setShowSaveModal(false)
|
||||
setSaveEmployeeId('')
|
||||
alert('已保存到员工档案')
|
||||
} catch (err: any) {
|
||||
alert('保存失败:' + (err.response?.data?.error?.message || '请稍后重试'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
@@ -345,10 +544,30 @@ function CaseTab() {
|
||||
|
||||
{result && (
|
||||
<Card>
|
||||
<h3 className="font-medium mb-3">分析结果</h3>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-medium">分析结果</h3>
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowSaveModal(true)}><Save className="w-4 h-4 mr-1" />保存到员工档案</Button>
|
||||
</div>
|
||||
<div className="text-xs text-gray-700 whitespace-pre-wrap">{result}</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{showSaveModal && (
|
||||
<Modal open onClose={() => setShowSaveModal(false)} size="sm">
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium">保存到员工档案</h3>
|
||||
<Label>选择员工</Label>
|
||||
<Select value={saveEmployeeId} onChange={(e) => setSaveEmployeeId(e.target.value)}>
|
||||
<option value="">选择员工</option>
|
||||
{(employees || []).map((e: any) => <option key={e.id} value={e.id}>{e.name}({e.department})</option>)}
|
||||
</Select>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowSaveModal(false)}>取消</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!saveEmployeeId}>保存</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert, UserPlus, AlertCircle } 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 } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
@@ -36,6 +36,8 @@ export default function Dashboard() {
|
||||
const [todoPageSize, setTodoPageSize] = useState(10)
|
||||
const queryClient = useQueryClient()
|
||||
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 { data, isLoading, refetch, isFetching } = useQuery<DashboardData>({
|
||||
queryKey: ['dashboard'],
|
||||
queryFn: async () => {
|
||||
@@ -62,6 +64,46 @@ export default function Dashboard() {
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
|
||||
})
|
||||
|
||||
const batchResolveMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => api.patch('/dashboard/todos/batch-resolve', { ids }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
setSelectedIds(new Set())
|
||||
},
|
||||
})
|
||||
|
||||
const batchIgnoreMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => api.patch('/dashboard/todos/batch-ignore', { ids }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
setSelectedIds(new Set())
|
||||
},
|
||||
})
|
||||
|
||||
const handleExportPayroll = () => {
|
||||
const month = payroll?.month || new Date().toISOString().slice(0, 7)
|
||||
window.open(`/api/v1/export/payroll?month=${month}`, '_blank')
|
||||
}
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelectAll = (ids: string[]) => {
|
||||
setSelectedIds(prev => {
|
||||
const allSelected = ids.every(id => prev.has(id))
|
||||
const next = new Set(prev)
|
||||
if (allSelected) ids.forEach(id => next.delete(id))
|
||||
else ids.forEach(id => next.add(id))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const riskTodos = data?.todos.filter((t) => t.type === 'CONTRACT' || t.type === 'TERMINATION' || t.type === 'ONBOARDING') || []
|
||||
const taskTodos = data?.todos.filter((t) => t.type === 'MONTHLY' || t.type === 'SALARY') || []
|
||||
const filteredTodos = activeTab === 'risk' ? riskTodos : taskTodos
|
||||
@@ -118,9 +160,9 @@ export default function Dashboard() {
|
||||
<h1 className="text-xs font-medium">{data.greeting}</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{payroll?.month} 月度总览</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => refetch()} disabled={isFetching}>
|
||||
<Button variant="secondary" size="sm" onClick={() => refetch()} disabled={isFetching} className={activeTab === 'risk' || activeTab === 'task' ? 'opacity-50 pointer-events-none' : ''}>
|
||||
<RefreshCw className={`w-4 h-4 mr-1 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
{isFetching ? '刷新中...' : '刷新'}
|
||||
{isFetching ? '刷新中...' : activeTab === 'payroll' ? '刷新薪税' : '刷新概览'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -167,6 +209,33 @@ 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-400"> 等{expiringContracts.length}人</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-danger" />
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* 本月工作动态 */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@@ -191,19 +260,57 @@ export default function Dashboard() {
|
||||
<Card>
|
||||
<h2 className="font-medium mb-3">风险分布</h2>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={() => setDrillDownType(drillDownType === 'CONTRACT' ? null : 'CONTRACT')}
|
||||
className={`text-center p-3 rounded-lg transition-colors ${drillDownType === 'CONTRACT' ? 'bg-primary/10' : 'hover:bg-gray-50'}`}
|
||||
>
|
||||
<div className="text-lg font-bold text-primary">{data.riskDistribution.contract}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">合同风险</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
{data.riskDistribution.contract > 0 && <ChevronRight className="w-3 h-3 text-primary mx-auto mt-1" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDrillDownType(drillDownType === 'SALARY' ? null : 'SALARY')}
|
||||
className={`text-center p-3 rounded-lg transition-colors ${drillDownType === 'SALARY' ? 'bg-warning/10' : 'hover:bg-gray-50'}`}
|
||||
>
|
||||
<div className="text-lg font-bold text-warning">{data.riskDistribution.salary}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">薪资风险</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
{data.riskDistribution.salary > 0 && <ChevronRight className="w-3 h-3 text-warning mx-auto mt-1" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDrillDownType(drillDownType === 'TERMINATION' ? null : 'TERMINATION')}
|
||||
className={`text-center p-3 rounded-lg transition-colors ${drillDownType === 'TERMINATION' ? 'bg-danger/10' : 'hover:bg-gray-50'}`}
|
||||
>
|
||||
<div className="text-lg font-bold text-danger">{data.riskDistribution.termination}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">解聘风险</div>
|
||||
</div>
|
||||
{data.riskDistribution.termination > 0 && <ChevronRight className="w-3 h-3 text-danger mx-auto mt-1" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 下钻明细 */}
|
||||
{drillDownType && (
|
||||
<div className="mt-3 border-t pt-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-600">
|
||||
{drillDownType === 'CONTRACT' ? '合同' : drillDownType === 'SALARY' ? '薪资' : '解聘'}风险明细
|
||||
</span>
|
||||
<button onClick={() => setDrillDownType(null)} className="text-xs text-gray-400 hover:text-gray-600">收起</button>
|
||||
</div>
|
||||
{data.topRisks.filter(r => r.type === drillDownType).length > 0 ? (
|
||||
data.topRisks.filter(r => r.type === drillDownType).map((r) => (
|
||||
<Link key={r.id} to={r.actionUrl} className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-gray-50 text-xs">
|
||||
<AlertCircle className={`w-4 h-4 flex-shrink-0 ${r.level === 'high' ? 'text-danger' : 'text-warning'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-gray-800">{r.title}</div>
|
||||
{r.employeeName && <div className="text-gray-400">{r.employeeName}</div>}
|
||||
</div>
|
||||
<ArrowRight className="w-3 h-3 text-gray-400" />
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-gray-400 text-center py-2">暂无高风险项</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
@@ -213,9 +320,14 @@ export default function Dashboard() {
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-medium flex items-center gap-1.5"><Calculator className="w-4 h-4" />本月薪税费用总览</h2>
|
||||
<Link to="/money" className="text-xs text-primary hover:underline flex items-center gap-1">
|
||||
查看明细 <ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={handleExportPayroll} disabled={!payroll || payroll.payslipCount === 0}>
|
||||
<Download className="w-4 h-4 mr-1" />导出
|
||||
</Button>
|
||||
<Link to="/money" className="text-xs text-primary hover:underline flex items-center gap-1">
|
||||
查看明细 <ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{payroll && payroll.payslipCount > 0 ? (
|
||||
@@ -321,6 +433,36 @@ export default function Dashboard() {
|
||||
<EmptyState title="暂无待办" description="所有事项已处理完毕" />
|
||||
) : (
|
||||
<>
|
||||
{/* 批量操作栏 */}
|
||||
<div className="flex items-center gap-2 mb-2 pb-2 border-b">
|
||||
<button
|
||||
onClick={() => toggleSelectAll(filteredTodos.map(t => t.id))}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{filteredTodos.every(t => selectedIds.has(t.id)) ? '取消全选' : '全选'}
|
||||
</button>
|
||||
{selectedIds.size > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-gray-400">已选 {selectedIds.size} 项</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => batchResolveMutation.mutate([...selectedIds])}
|
||||
disabled={batchResolveMutation.isPending}
|
||||
>
|
||||
<Check className="w-3 h-3 mr-1" />批量完成
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => batchIgnoreMutation.mutate([...selectedIds])}
|
||||
disabled={batchIgnoreMutation.isPending}
|
||||
>
|
||||
<X className="w-3 h-3 mr-1" />批量忽略
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Pagination page={todoPage} pageSize={todoPageSize} total={filteredTodos.length} onPageChange={setTodoPage} onPageSizeChange={(s) => { setTodoPageSize(s); setTodoPage(1) }} />
|
||||
<div className="space-y-2">
|
||||
{filteredTodos.slice((todoPage - 1) * todoPageSize, todoPage * todoPageSize).map((todo) => (
|
||||
@@ -328,13 +470,21 @@ export default function Dashboard() {
|
||||
key={todo.id}
|
||||
className="flex items-center justify-between px-2.5 py-2 rounded-md hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Link to={todo.actionUrl} className="flex items-center gap-2.5 flex-1">
|
||||
<TodoIcon type={todo.type} level={todo.level} />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-gray-800">{todo.title}</span>
|
||||
<span className="text-xs text-gray-400 flex items-center gap-1"><Clock className="w-3 h-3" />{todo.description}</span>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2.5 flex-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(todo.id)}
|
||||
onChange={() => toggleSelect(todo.id)}
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
/>
|
||||
<Link to={todo.actionUrl} className="flex items-center gap-2.5 flex-1">
|
||||
<TodoIcon type={todo.type} level={todo.level} />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-gray-800">{todo.title}</span>
|
||||
<span className="text-xs text-gray-400 flex items-center gap-1"><Clock className="w-3 h-3" />{todo.description}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => resolveMutation.mutate(todo.id)}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useMemo, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Calculator, AlertCircle, Info, Save, Check, Upload, Bell, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText, History } from 'lucide-react'
|
||||
import { Calculator, AlertCircle, Info, Save, Check, Upload, Bell, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText, History, X } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label, Select } from '../components/ui/Input'
|
||||
import Modal from '../components/ui/Modal'
|
||||
import EmptyState from '../components/ui/EmptyState'
|
||||
import Pagination from '../components/ui/Pagination'
|
||||
|
||||
@@ -783,6 +784,8 @@ function OvertimeCalculator() {
|
||||
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [previewData, setPreviewData] = useState<any[]>([])
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editForm, setEditForm] = useState({ weekdayHours: 0, weekendHours: 0, holidayHours: 0 })
|
||||
|
||||
// 加班费规则配置
|
||||
const { data: config, isLoading: configLoading } = useQuery<any>({
|
||||
@@ -828,6 +831,33 @@ function OvertimeCalculator() {
|
||||
},
|
||||
})
|
||||
|
||||
// 更新单条加班记录
|
||||
const updateOvertimeMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) =>
|
||||
api.put(`/payroll/overtime/${id}`, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['overtime-records'] })
|
||||
setEditingId(null)
|
||||
},
|
||||
})
|
||||
|
||||
// 开始编辑
|
||||
const startEdit = (record: any) => {
|
||||
setEditingId(record.id)
|
||||
setEditForm({
|
||||
weekdayHours: record.weekdayHours || 0,
|
||||
weekendHours: record.weekendHours || 0,
|
||||
holidayHours: record.holidayHours || 0,
|
||||
})
|
||||
}
|
||||
|
||||
// 保存编辑
|
||||
const saveEdit = () => {
|
||||
if (editingId) {
|
||||
updateOvertimeMutation.mutate({ id: editingId, data: editForm })
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
@@ -1046,6 +1076,7 @@ function OvertimeCalculator() {
|
||||
<th className="py-2 text-right">节假日(h)</th>
|
||||
<th className="py-2 text-right">加班费</th>
|
||||
<th className="py-2 text-center">状态</th>
|
||||
<th className="py-2 text-center">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1053,9 +1084,46 @@ function OvertimeCalculator() {
|
||||
<tr key={r.id} className="border-b last:border-0 hover:bg-gray-50">
|
||||
<td className="py-2">{r.employee?.name}</td>
|
||||
<td className="py-2 text-gray-500">{r.employee?.department}</td>
|
||||
<td className="py-2 text-right">{r.weekdayHours || '-'}</td>
|
||||
<td className="py-2 text-right">{r.weekendHours || '-'}</td>
|
||||
<td className="py-2 text-right">{r.holidayHours || '-'}</td>
|
||||
{editingId === r.id ? (
|
||||
<>
|
||||
<td className="py-1 text-right">
|
||||
<input
|
||||
type="number"
|
||||
className="w-16 text-right border rounded px-1 py-0.5 text-xs"
|
||||
value={editForm.weekdayHours}
|
||||
onChange={(e) => setEditForm({ ...editForm, weekdayHours: Number(e.target.value) })}
|
||||
min="0"
|
||||
step="0.5"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1 text-right">
|
||||
<input
|
||||
type="number"
|
||||
className="w-16 text-right border rounded px-1 py-0.5 text-xs"
|
||||
value={editForm.weekendHours}
|
||||
onChange={(e) => setEditForm({ ...editForm, weekendHours: Number(e.target.value) })}
|
||||
min="0"
|
||||
step="0.5"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1 text-right">
|
||||
<input
|
||||
type="number"
|
||||
className="w-16 text-right border rounded px-1 py-0.5 text-xs"
|
||||
value={editForm.holidayHours}
|
||||
onChange={(e) => setEditForm({ ...editForm, holidayHours: Number(e.target.value) })}
|
||||
min="0"
|
||||
step="0.5"
|
||||
/>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td className="py-2 text-right cursor-pointer hover:text-blue-600" onClick={() => startEdit(r)}>{r.weekdayHours || '-'}</td>
|
||||
<td className="py-2 text-right cursor-pointer hover:text-blue-600" onClick={() => startEdit(r)}>{r.weekendHours || '-'}</td>
|
||||
<td className="py-2 text-right cursor-pointer hover:text-blue-600" onClick={() => startEdit(r)}>{r.holidayHours || '-'}</td>
|
||||
</>
|
||||
)}
|
||||
<td className="py-2 text-right font-medium text-gray-700">
|
||||
{r.totalPay > 0 ? `¥${fmt(r.totalPay)}` : <span className="text-gray-400">待计算</span>}
|
||||
</td>
|
||||
@@ -1066,6 +1134,37 @@ function OvertimeCalculator() {
|
||||
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-600 text-xs">未入批次</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-center">
|
||||
{editingId === r.id ? (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
onClick={saveEdit}
|
||||
disabled={updateOvertimeMutation.isPending}
|
||||
className="text-safe hover:text-green-700 disabled:opacity-50"
|
||||
title="保存"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingId(null)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
title="取消"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
!r.batchId && (
|
||||
<button
|
||||
onClick={() => startEdit(r)}
|
||||
className="text-gray-400 hover:text-blue-600"
|
||||
title="编辑"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -1099,6 +1198,16 @@ function PayslipManager() {
|
||||
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [showTaxPreview, setShowTaxPreview] = useState(false)
|
||||
const [previewEmployeeId, setPreviewEmployeeId] = useState('')
|
||||
const [previewData, setPreviewData] = useState({
|
||||
baseSalary: 0,
|
||||
overtimePay: 0,
|
||||
allowance: 0,
|
||||
deduction: 0,
|
||||
bonus: 0,
|
||||
specialDeduction: 0,
|
||||
})
|
||||
|
||||
const { data: payslips, isLoading } = useQuery<any[]>({
|
||||
queryKey: ['payslips', month],
|
||||
@@ -1123,6 +1232,16 @@ function PayslipManager() {
|
||||
},
|
||||
})
|
||||
|
||||
const taxPreviewMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/payroll/tax-preview', data),
|
||||
onSuccess: (res: any) => {
|
||||
setTaxResult(res.data)
|
||||
setShowTaxPreview(true)
|
||||
},
|
||||
})
|
||||
|
||||
const [taxResult, setTaxResult] = useState<any>(null)
|
||||
|
||||
const confirmedCount = payslips?.filter((p: any) => p.confirmedAt).length || 0
|
||||
const unconfirmedCount = payslips ? payslips.length - confirmedCount : 0
|
||||
|
||||
@@ -1140,6 +1259,12 @@ function PayslipManager() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => setShowTaxPreview(true)}
|
||||
>
|
||||
<Calculator className="w-4 h-4 mr-1" />
|
||||
税率试算
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (confirm(`确认从 ${month} 已归档批次汇总生成工资条?这将覆盖已有的工资条数据。`)) {
|
||||
@@ -1210,6 +1335,74 @@ function PayslipManager() {
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 税率试算 Modal */}
|
||||
{showTaxPreview && (
|
||||
<Modal open onClose={() => { setShowTaxPreview(false); setTaxResult(null) }}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">工资条税率试算</h3>
|
||||
<button onClick={() => { setShowTaxPreview(false); setTaxResult(null) }} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">基本工资</label>
|
||||
<Input type="number" value={previewData.baseSalary || ''} onChange={(e) => setPreviewData({ ...previewData, baseSalary: Number(e.target.value) })} placeholder="请输入" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">加班费</label>
|
||||
<Input type="number" value={previewData.overtimePay || ''} onChange={(e) => setPreviewData({ ...previewData, overtimePay: Number(e.target.value) })} placeholder="请输入" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">津贴</label>
|
||||
<Input type="number" value={previewData.allowance || ''} onChange={(e) => setPreviewData({ ...previewData, allowance: Number(e.target.value) })} placeholder="请输入" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">奖金</label>
|
||||
<Input type="number" value={previewData.bonus || ''} onChange={(e) => setPreviewData({ ...previewData, bonus: Number(e.target.value) })} placeholder="请输入" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">扣款</label>
|
||||
<Input type="number" value={previewData.deduction || ''} onChange={(e) => setPreviewData({ ...previewData, deduction: Number(e.target.value) })} placeholder="请输入" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">专项附加扣除</label>
|
||||
<Input type="number" value={previewData.specialDeduction || ''} onChange={(e) => setPreviewData({ ...previewData, specialDeduction: Number(e.target.value) })} placeholder="请输入" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => taxPreviewMutation.mutate({ month, ...previewData })} disabled={taxPreviewMutation.isPending} className="flex-1">
|
||||
{taxPreviewMutation.isPending ? '计算中...' : '计算'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => {
|
||||
setPreviewData({ baseSalary: 0, overtimePay: 0, allowance: 0, deduction: 0, bonus: 0, specialDeduction: 0 })
|
||||
setTaxResult(null)
|
||||
}}>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{taxResult && (
|
||||
<div className="border rounded-md p-3 space-y-2">
|
||||
<div className="text-xs font-medium text-gray-600 mb-2">计算结果</div>
|
||||
{taxResult.breakdown.map((item: any, i: number) => (
|
||||
<div key={i} className={`flex justify-between text-xs ${i === taxResult.breakdown.length - 1 ? 'font-bold border-t pt-2 mt-2' : ''} ${item.value < 0 ? 'text-danger' : item.value > 0 && i < taxResult.breakdown.length - 1 ? 'text-gray-500' : ''}`}>
|
||||
<span>{item.label}</span>
|
||||
<span>{item.value < 0 ? `-¥${fmt(Math.abs(item.value))}` : `¥${fmt(item.value)}`}</span>
|
||||
</div>
|
||||
))}
|
||||
{taxResult.ytdPayslipCount > 0 && (
|
||||
<div className="text-xs text-gray-400 mt-2">注:已累计{taxResult.ytdPayslipCount}条工资条计算个税</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -982,6 +982,24 @@ function ContractInfo({ employeeId, contracts, hireDate }: { employeeId: string;
|
||||
const handleContractFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// 文件类型校验
|
||||
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/heic']
|
||||
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic']
|
||||
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
|
||||
if (!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) {
|
||||
alert('不支持的文件格式,请上传 PDF、JPG、PNG 或 HEIC 格式')
|
||||
return
|
||||
}
|
||||
|
||||
// 文件大小校验(10MB)
|
||||
const maxSize = 10 * 1024 * 1024
|
||||
if (file.size > maxSize) {
|
||||
const formatSize = (bytes: number) => bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(0)}KB` : `${(bytes / 1024 / 1024).toFixed(1)}MB`
|
||||
alert(`文件过大,请上传小于 10MB 的文件(当前: ${formatSize(file.size)})`)
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
setForm({ ...form, attachmentUrl: event.target?.result as string })
|
||||
@@ -1668,6 +1686,7 @@ function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
const [form, setForm] = useState({
|
||||
name: '', department: '', hireDate: todayStr, monthlySalary: '',
|
||||
idCardNumber: '', gender: '男' as '男' | '女', phone: '',
|
||||
city: '北京',
|
||||
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
|
||||
signDate: '', startDate: todayStr, endDate: defaultEndDate,
|
||||
contractYears: 3, probationMonths: 0, probationSalary: 0,
|
||||
@@ -1826,6 +1845,7 @@ function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>手机号</Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></div>
|
||||
<div><Label>参保城市</Label><select className="w-full text-xs border rounded px-2 py-1.5" value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })}><option value="北京">北京</option><option value="上海">上海</option><option value="广州">广州</option><option value="深圳">深圳</option><option value="杭州">杭州</option></select></div>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<Label>社保公积金</Label>
|
||||
@@ -1927,6 +1947,23 @@ function AttachmentInfo({ employeeId, attachments }: { employeeId: string; attac
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// 文件类型校验
|
||||
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/heic']
|
||||
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic']
|
||||
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
|
||||
if (!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) {
|
||||
alert('不支持的文件格式,请上传 PDF、JPG、PNG 或 HEIC 格式')
|
||||
return
|
||||
}
|
||||
|
||||
// 文件大小校验(10MB)
|
||||
const maxSize = 10 * 1024 * 1024
|
||||
if (file.size > maxSize) {
|
||||
alert(`文件过大,请上传小于 10MB 的文件(当前: ${formatSize(file.size)})`)
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const fileUrl = event.target?.result as string
|
||||
@@ -1957,6 +1994,7 @@ function AttachmentInfo({ employeeId, attachments }: { employeeId: string; attac
|
||||
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={addAttachmentMutation.isPending} className="shrink-0 whitespace-nowrap">
|
||||
{addAttachmentMutation.isPending ? '上传中...' : '上传附件'}
|
||||
</Button>
|
||||
<span className="text-gray-400 text-xs">支持 PDF/JPG/PNG,最大 10MB</span>
|
||||
</div>
|
||||
{attachments?.length ? (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -12,6 +12,7 @@ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDig
|
||||
export default function SocialInsurance() {
|
||||
const queryClient = useQueryClient()
|
||||
const [tab, setTab] = useState<'social' | 'housing' | 'monthly'>('social')
|
||||
const [city, setCity] = useState<string>('北京')
|
||||
const [base, setBase] = useState(8000)
|
||||
const [showNewVersion, setShowNewVersion] = useState(false)
|
||||
const [showVersions, setShowVersions] = useState(false)
|
||||
@@ -36,26 +37,35 @@ export default function SocialInsurance() {
|
||||
baseMin: 6326, baseMax: 33891,
|
||||
})
|
||||
|
||||
const { data: config } = useQuery<any>({
|
||||
queryKey: ['social-config'],
|
||||
// 获取城市列表
|
||||
const { data: cities = [] } = useQuery<string[]>({
|
||||
queryKey: ['social-config-cities'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/social/config') as any
|
||||
const res = await api.get('/social/config/cities') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: config } = useQuery<any>({
|
||||
queryKey: ['social-config', city],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/social/config', { params: { city } }) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: housingConfig } = useQuery<any>({
|
||||
queryKey: ['housing-config'],
|
||||
queryKey: ['housing-config', city],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/social/housing-config') as any
|
||||
const res = await api.get('/social/housing-config', { params: { city } }) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: versions } = useQuery<any[]>({
|
||||
queryKey: ['social-config-versions'],
|
||||
queryKey: ['social-config-versions', city],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/social/config/versions') as any
|
||||
const res = await api.get('/social/config/versions', { params: { city } }) as any
|
||||
return res.data
|
||||
},
|
||||
enabled: showVersions && tab === 'social',
|
||||
@@ -174,19 +184,19 @@ export default function SocialInsurance() {
|
||||
})
|
||||
|
||||
const resetAdjustMutation = useMutation({
|
||||
mutationFn: () => api.post(`/social/config/${config?.id}/reset-adjustment`),
|
||||
mutationFn: () => api.post(`/social/config/${config?.id}/reset-adjustment`, { city }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config-versions'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config', city] })
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config-versions', city] })
|
||||
alert('社保基数调整已重置,可以重新调整')
|
||||
},
|
||||
})
|
||||
|
||||
const resetHousingAdjustMutation = useMutation({
|
||||
mutationFn: () => api.post(`/social/housing-config/${housingConfig?.id}/reset-adjustment`),
|
||||
mutationFn: () => api.post(`/social/housing-config/${housingConfig?.id}/reset-adjustment`, { city }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config-versions'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config', city] })
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config-versions', city] })
|
||||
alert('公积金基数调整已重置,可以重新调整')
|
||||
},
|
||||
})
|
||||
@@ -237,8 +247,8 @@ export default function SocialInsurance() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
<div className="flex gap-1 border-b">
|
||||
{/* Tab 切换 + 城市选择 */}
|
||||
<div className="flex items-center gap-4 border-b">
|
||||
{(['social', 'housing', 'monthly'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
@@ -250,6 +260,20 @@ export default function SocialInsurance() {
|
||||
{t === 'social' ? '社保' : t === 'housing' ? '公积金' : '月度办理'}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<label className="text-xs text-gray-500">城市:</label>
|
||||
<select
|
||||
className="text-xs border rounded px-2 py-1.5"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
>
|
||||
{cities.length > 0 ? (
|
||||
cities.map((c) => <option key={c} value={c}>{c}</option>)
|
||||
) : (
|
||||
<option value="北京">北京</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== 社保 / 公积金 Tab ========== */}
|
||||
|
||||
@@ -105,6 +105,15 @@ export interface DashboardData {
|
||||
salary: number
|
||||
termination: number
|
||||
}
|
||||
topRisks: {
|
||||
id: string
|
||||
type: string
|
||||
level: string
|
||||
title: string
|
||||
description: string
|
||||
employeeName: string | null
|
||||
actionUrl: string
|
||||
}[]
|
||||
aiPrediction: {
|
||||
risks: unknown[]
|
||||
suggestion: string
|
||||
|
||||
Reference in New Issue
Block a user