优化: 大文件拆分+代码分割+按需加载+console清理+any类型替换
- Money.tsx (2260行→54行): 拆分为 money/ 子目录4个组件, React.lazy二级分割 - AIAssistant.tsx (2038行→63行): 拆分为 ai-assistant/ 子目录6个组件, React.lazy二级分割 - xlsx改为动态导入, OvertimeTab从345KB降至12.7KB - api-services.ts: 请求参数 any→Record<string,unknown> - 移除前端3处console.log残留 - 后端console替换为pino logger - 前后端未使用import/变量清理 - Zod schema验证: termination/platform/special-status/work-process - 新增 leave.routes.ts, acceptance-test.routes.ts - UI组件: PageGuide, QueryError, Stepper
This commit is contained in:
+134
@@ -0,0 +1,134 @@
|
|||||||
|
# 20260803 优化清单
|
||||||
|
|
||||||
|
## 一、验收测试清单(问题 1-3)
|
||||||
|
|
||||||
|
### 问题 1:保存不提示成功,重新进入记录丢失
|
||||||
|
- **根因**:acceptance-test.html 使用 localStorage 按验收人姓名保存(STORAGE_PREFIX + name),但在 iframe 中 localStorage 可能因跨域限制无法写入。autoSave() 静默执行无反馈,saveCurrent() 虽有 showToast 但用户可能未看到。
|
||||||
|
- **修复方向**:
|
||||||
|
1. 保存按钮增加明显 toast 提示
|
||||||
|
2. 检查 iframe 中 localStorage 是否可用,不可用时降级到服务端存储
|
||||||
|
3. 进入页面时自动加载已保存的验收人记录
|
||||||
|
- **优先级**:P1 高
|
||||||
|
|
||||||
|
### 问题 2:无提交按钮,重新进入记录不显示
|
||||||
|
- **根因**:acceptance-test.html 只有「保存」「下载」「打印」按钮,没有「提交」功能。重新进入时需手动在验收人输入框输入姓名触发 loadSaved,但用户可能不知道这个操作。
|
||||||
|
- **修复方向**:
|
||||||
|
1. 增加「提交验收报告」按钮,将数据发送到后端持久化
|
||||||
|
2. 页面加载时自动显示已保存的验收人列表供选择
|
||||||
|
3. 优化交互流程,进入时自动加载上次记录
|
||||||
|
- **优先级**:P1 高
|
||||||
|
|
||||||
|
### 问题 3:总体结论不应手动勾选
|
||||||
|
- **根因**:当前 conclusion-section 中总体结论是 radio 按钮手动选择(acceptance-test.html:592-598),updateStats() 已计算了通过/失败/部分通过/未测试数量,但未自动推导结论。
|
||||||
|
- **修复方向**:移除手动 radio,根据测试结果自动计算:通过率 100% → 通过,有 fail → 不通过,仅有 partial → 有条件通过。汇总主要问题从 fail/partial 的备注中提取。
|
||||||
|
- **优先级**:P1 高
|
||||||
|
|
||||||
|
## 二、花名册导入(问题 4-5、11)
|
||||||
|
|
||||||
|
### 问题 4:导入错误不提示具体内容
|
||||||
|
- **根因**:Settings.tsx:1077-1127 的导入结果已有错误详情展示(result.errors 数组,最多显示前 10 条),但仅在导入后显示。如果用户跳过「预览」直接导入,错误信息只在结果区域展示,可能被忽略。预览功能(handlePreview)已有完整错误展示。
|
||||||
|
- **修复方向**:
|
||||||
|
1. 导入失败时增加醒目 toast 提示「有 N 条错误,请查看详情」
|
||||||
|
2. 错误区域默认展开,不折叠
|
||||||
|
3. 增加「跳过错误行,仅导入正常行」选项
|
||||||
|
- **优先级**:P1 高
|
||||||
|
|
||||||
|
### 问题 5:按人为员工办理入职提示乱码
|
||||||
|
- **根因**:WorkProcess.tsx:453-456 中表单数据以 key 原始字段名显示(如 employeeName、idCardNumber),未做中文标签映射。generateDocument() 生成的文书内容是中文,但 formData 的 key 是英文,显示为「乱码」感。
|
||||||
|
- **修复方向**:在 WorkProcess.tsx 的表单信息展示区域增加字段名中文映射表,将 employeeName → 员工姓名、idCardNumber → 身份证号 等。
|
||||||
|
- **优先级**:P0 紧急
|
||||||
|
|
||||||
|
### 问题 11:导入后无员工ID显示
|
||||||
|
- **根因**:import.routes.ts:326-327 导入成功后 result.details 记录了 { sheet, row, name, status: 'success', message: '导入成功' },但未返回 employeeId。前端 Settings.tsx:1077-1086 只显示「成功导入 N 人」,不显示具体 ID。
|
||||||
|
- **修复方向**:
|
||||||
|
1. 后端导入时在 result.details 中加入 employeeId
|
||||||
|
2. 前端结果展示中增加员工 ID 列
|
||||||
|
3. 花名册列表中确保 ID 列可见
|
||||||
|
- **优先级**:P1 高
|
||||||
|
|
||||||
|
## 三、工资薪税(问题 6-7)
|
||||||
|
|
||||||
|
### 问题 6:批量选择部分人员 + 个税未算出
|
||||||
|
- **根因**:
|
||||||
|
- 批量选人:Money.tsx 已有 mode: 'custom' 和 CustomEmployeeSelector 组件(Money.tsx:331-333),支持按部门筛选、搜索、勾选员工。但用户可能不知道此功能。
|
||||||
|
- 个税未算出:payroll.service.ts:230-256 使用累计预扣法,需要已归档批次的历史数据来计算累计应纳税所得额。如果是首次使用或当月无已归档批次,ytdTaxableIncome 可能 ≤ 5000 起征点,导致个税为 0。税前工资超 5000 但个税为 0 的原因可能是:①累计减除费用(5000×月数)后应纳税所得额 ≤ 0;②社保公积金扣除后剩余 ≤ 5000/月。
|
||||||
|
- **修复方向**:
|
||||||
|
1. UI 上更突出「自定义选择员工」模式
|
||||||
|
2. 个税计算增加明细展示,让用户看到「累计收入 - 累计减除 - 累计社保 = 应纳税所得额」,理解为何个税为 0
|
||||||
|
3. 检查是否有 bug 导致 ytdTaxableIncome 计算错误
|
||||||
|
- **优先级**:P1 高
|
||||||
|
|
||||||
|
### 问题 7:个人保费与实缴一致 + 手动调整
|
||||||
|
- **根因**:payroll.service.ts:165-206 社保计算基于 socialInsuranceConfig 配置表的比例自动计算。如果配置比例与实际社保局核定金额有差异,无法自动匹配。overrideSocial 参数已支持手动覆盖(payroll2.routes.ts:462-472),但前端可能未暴露此编辑入口。
|
||||||
|
- **修复方向**:
|
||||||
|
1. 工资表条目编辑界面增加「个人社保」「个人公积金」可编辑字段
|
||||||
|
2. 显示「系统计算值」和「实际缴纳值」对比,允许手动调整差异
|
||||||
|
3. 增加「匹配参保地政策」自动拉取配置功能
|
||||||
|
- **优先级**:P2 中
|
||||||
|
|
||||||
|
## 四、审批流程(问题 8)
|
||||||
|
|
||||||
|
### 问题 8:休假审批需要审批流程
|
||||||
|
- **现状**:系统中没有独立的休假审批模块和审批流引擎。WorkProcess 有简单的状态流转(DRAFT → PENDING → APPROVED/REJECTED),但不是通用审批流。
|
||||||
|
- **修复方向**:需要新建审批流模块,包括:
|
||||||
|
1. 审批流配置(审批节点、审批人、条件)
|
||||||
|
2. 休假申请提交
|
||||||
|
3. 审批进度追踪
|
||||||
|
4. 审批通知
|
||||||
|
- **优先级**:P3 规划(较大的功能开发,建议单独规划)
|
||||||
|
|
||||||
|
## 五、证明开具(问题 9)
|
||||||
|
|
||||||
|
### 问题 9:证明开具添加自定义模板
|
||||||
|
- **现状**:work-process.service.ts:247-275 的 generateDocument 只支持 INCOME_CERT 和 LEAVING_CERT 两种硬编码模板。EnterpriseTemplate 模块支持自定义模板(Templates.tsx),但 WorkProcess 的证明开具未调用企业模板。
|
||||||
|
- **修复方向**:
|
||||||
|
1. WorkProcess 证明开具功能关联 EnterpriseTemplate 表,允许选择企业自定义模板
|
||||||
|
2. 支持变量替换({{employeeName}} 等)
|
||||||
|
3. 允许用户新建模板并分类管理
|
||||||
|
- **优先级**:P2 中
|
||||||
|
|
||||||
|
## 六、工作台布局(问题 10)
|
||||||
|
|
||||||
|
### 问题 10:工作台内容杂乱,需分区分类
|
||||||
|
- **现状**:Dashboard.tsx 已有 Tab 分区(概览/风险提醒/月度任务),TaskCenter 也按分类分组展示。但概览 Tab 下内容较多(统计卡片、任务中心、成本分析、活动统计等)可能显得杂乱。
|
||||||
|
- **修复方向**:
|
||||||
|
1. 概览 Tab 按「待办事项」「人力概览」「薪税概览」「合规风险」分区展示,用卡片或分隔线区分
|
||||||
|
2. 增加折叠/展开功能
|
||||||
|
3. 调整信息密度,次要信息收起
|
||||||
|
- **优先级**:P2 中
|
||||||
|
- **状态**:✅ 已完成 — 已拆分为 5 个 Tab(概览/风险提醒/月度任务/人力成本/人员分析),支持 Tab 级别和区域级别的显示设置
|
||||||
|
|
||||||
|
## 七、离职操作(问题 12-13)
|
||||||
|
|
||||||
|
### 问题 12:离职操作全选填,可跳过必填项
|
||||||
|
- **根因**:Termination.tsx:527-534 的 canProceed() 函数中,step 2/3/4 都直接 return true,没有校验必填项。termination.schema.ts 的 schema 中只有 employeeId 和 reason 是必填,其他字段都可选。
|
||||||
|
- **修复方向**:
|
||||||
|
1. step 1(解聘方式)增加 terminationDate 必填校验(已有)
|
||||||
|
2. step 2(合规检查)要求至少勾选所有 suggestionType: 'required' 的检查项
|
||||||
|
3. step 3(费用结算)如果有补偿金,要求确认金额
|
||||||
|
4. step 4(工作交接)要求至少完成关键交接项
|
||||||
|
- **优先级**:P1 高
|
||||||
|
|
||||||
|
### 问题 13:补偿金手动修改后确认阶段仍显示系统预估
|
||||||
|
- **根因**:Termination.tsx:536-547 的 handleSave() 使用 costResult?.totalSeverance(系统计算值),而非用户可能手动调整后的值。handleSaveDraft() 使用 costResult?.grandTotal,也未考虑手动调整。compAdjustments 状态存在但未在保存时正确应用到最终补偿金。
|
||||||
|
- **修复方向**:
|
||||||
|
1. 保存时使用 costResult.grandTotal + compAdjustments 的合计值
|
||||||
|
2. 确认步骤显示「系统预估 ¥X + 手动调整 ¥Y = 实际补偿 ¥Z」
|
||||||
|
3. 后端 createDraft/updateDraft 已支持 compensationBreakdown.adjustments,前端需正确传递
|
||||||
|
- **优先级**:P0 紧急
|
||||||
|
|
||||||
|
## 八、用工文本模板(问题 14)
|
||||||
|
|
||||||
|
### 问题 14:用工文本模板优化为公司统一版本
|
||||||
|
- **现状**:work-process.service.ts 的 generateDocument 使用硬编码模板,EnterpriseTemplate 表支持自定义但未关联。
|
||||||
|
- **修复方向**:与问题 9 同一方案——将 WorkProcess 的文书生成改为从 EnterpriseTemplate 表读取模板内容,支持变量替换,管理员可统一维护模板版本。
|
||||||
|
- **优先级**:P2 中
|
||||||
|
|
||||||
|
## 优先级汇总
|
||||||
|
|
||||||
|
| 优先级 | 问题 | 原因 | 状态 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| P0 紧急 | 5、13 | 功能性 bug,影响业务正确性 | 待修复 |
|
||||||
|
| P1 高 | 1、2、3、4、6、11、12 | 用户体验差或操作不完整 | 待修复 |
|
||||||
|
| P2 中 | 7、9、10、14 | 功能增强和优化 | 问题10已完成 |
|
||||||
|
| P3 规划 | 8 | 新功能开发,需单独规划 | 待规划 |
|
||||||
Generated
+243
@@ -24,6 +24,8 @@
|
|||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"openai": "^6.48.0",
|
"openai": "^6.48.0",
|
||||||
|
"pino": "^10.3.1",
|
||||||
|
"pino-pretty": "^13.1.3",
|
||||||
"redis": "^6.1.0",
|
"redis": "^6.1.0",
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^10.0.0",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
@@ -569,6 +571,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@pinojs/redact": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@prisma/client": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "5.22.0",
|
"version": "5.22.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@prisma/client/-/client-5.22.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@prisma/client/-/client-5.22.0.tgz",
|
||||||
@@ -1128,6 +1136,15 @@
|
|||||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/atomic-sleep": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -1456,6 +1473,12 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/colorette": {
|
||||||
|
"version": "2.0.20",
|
||||||
|
"resolved": "https://registry.npmmirror.com/colorette/-/colorette-2.0.20.tgz",
|
||||||
|
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/compress-commons": {
|
"node_modules/compress-commons": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/compress-commons/-/compress-commons-4.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/compress-commons/-/compress-commons-4.1.2.tgz",
|
||||||
@@ -1613,6 +1636,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dateformat": {
|
||||||
|
"version": "4.6.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/dateformat/-/dateformat-4.6.3.tgz",
|
||||||
|
"integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dayjs": {
|
"node_modules/dayjs": {
|
||||||
"version": "1.11.21",
|
"version": "1.11.21",
|
||||||
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz",
|
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz",
|
||||||
@@ -1945,6 +1977,12 @@
|
|||||||
"express": ">= 4.11"
|
"express": ">= 4.11"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-copy": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/fast-copy/-/fast-copy-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-eVAiWVNPSEGIzDl5yPuLrx8fNMogScXvD9xp1Kzd41FjRIz2I3sSIcxsFeM5EzFfHAfobdvs8ZySffUopljvIA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-csv": {
|
"node_modules/fast-csv": {
|
||||||
"version": "4.3.6",
|
"version": "4.3.6",
|
||||||
"resolved": "https://registry.npmmirror.com/fast-csv/-/fast-csv-4.3.6.tgz",
|
"resolved": "https://registry.npmmirror.com/fast-csv/-/fast-csv-4.3.6.tgz",
|
||||||
@@ -1958,6 +1996,12 @@
|
|||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-safe-stringify": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@@ -2201,6 +2245,12 @@
|
|||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/help-me": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/help-me/-/help-me-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
@@ -2353,6 +2403,15 @@
|
|||||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/joycon": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/joycon/-/joycon-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jsonwebtoken": {
|
"node_modules/jsonwebtoken": {
|
||||||
"version": "9.0.3",
|
"version": "9.0.3",
|
||||||
"resolved": "https://registry.npmmirror.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||||
@@ -2869,6 +2928,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/on-exit-leak-free": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
@@ -2985,6 +3053,79 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pino": {
|
||||||
|
"version": "10.3.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/pino/-/pino-10.3.1.tgz",
|
||||||
|
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@pinojs/redact": "^0.4.0",
|
||||||
|
"atomic-sleep": "^1.0.0",
|
||||||
|
"on-exit-leak-free": "^2.1.0",
|
||||||
|
"pino-abstract-transport": "^3.0.0",
|
||||||
|
"pino-std-serializers": "^7.0.0",
|
||||||
|
"process-warning": "^5.0.0",
|
||||||
|
"quick-format-unescaped": "^4.0.3",
|
||||||
|
"real-require": "^0.2.0",
|
||||||
|
"safe-stable-stringify": "^2.3.1",
|
||||||
|
"sonic-boom": "^4.0.1",
|
||||||
|
"thread-stream": "^4.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pino": "bin.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pino-abstract-transport": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pino-pretty": {
|
||||||
|
"version": "13.1.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/pino-pretty/-/pino-pretty-13.1.3.tgz",
|
||||||
|
"integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"colorette": "^2.0.7",
|
||||||
|
"dateformat": "^4.6.3",
|
||||||
|
"fast-copy": "^4.0.0",
|
||||||
|
"fast-safe-stringify": "^2.1.1",
|
||||||
|
"help-me": "^5.0.0",
|
||||||
|
"joycon": "^3.1.1",
|
||||||
|
"minimist": "^1.2.6",
|
||||||
|
"on-exit-leak-free": "^2.1.0",
|
||||||
|
"pino-abstract-transport": "^3.0.0",
|
||||||
|
"pump": "^3.0.0",
|
||||||
|
"secure-json-parse": "^4.0.0",
|
||||||
|
"sonic-boom": "^4.0.1",
|
||||||
|
"strip-json-comments": "^5.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pino-pretty": "bin.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pino-pretty/node_modules/strip-json-comments": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pino-std-serializers": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/prisma": {
|
"node_modules/prisma": {
|
||||||
"version": "5.22.0",
|
"version": "5.22.0",
|
||||||
"resolved": "https://registry.npmmirror.com/prisma/-/prisma-5.22.0.tgz",
|
"resolved": "https://registry.npmmirror.com/prisma/-/prisma-5.22.0.tgz",
|
||||||
@@ -3011,6 +3152,22 @@
|
|||||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/process-warning": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/process-warning/-/process-warning-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
"resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
@@ -3024,6 +3181,16 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pump": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"end-of-stream": "^1.1.0",
|
||||||
|
"once": "^1.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.3",
|
"version": "6.15.3",
|
||||||
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.3.tgz",
|
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.3.tgz",
|
||||||
@@ -3040,6 +3207,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/quick-format-unescaped": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/range-parser": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz",
|
"resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
@@ -3121,6 +3294,15 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/real-require": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/real-require/-/real-require-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/redis": {
|
"node_modules/redis": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/redis/-/redis-6.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/redis/-/redis-6.1.0.tgz",
|
||||||
@@ -3192,6 +3374,15 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-stable-stringify": {
|
||||||
|
"version": "2.5.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||||
|
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/safer-buffer": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
@@ -3210,6 +3401,22 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/secure-json-parse": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.8.5",
|
"version": "7.8.5",
|
||||||
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz",
|
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz",
|
||||||
@@ -3351,6 +3558,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sonic-boom": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"atomic-sleep": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
"version": "0.6.1",
|
"version": "0.6.1",
|
||||||
"resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz",
|
"resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz",
|
||||||
@@ -3372,6 +3588,15 @@
|
|||||||
"source-map": "^0.6.0"
|
"source-map": "^0.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sprintf-js": {
|
"node_modules/sprintf-js": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||||
@@ -3465,6 +3690,24 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/thread-stream": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/thread-stream/-/thread-stream-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"real-require": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/thread-stream/node_modules/real-require": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/real-require/-/real-require-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tmp": {
|
"node_modules/tmp": {
|
||||||
"version": "0.2.7",
|
"version": "0.2.7",
|
||||||
"resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.2.7.tgz",
|
"resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.2.7.tgz",
|
||||||
|
|||||||
@@ -29,6 +29,8 @@
|
|||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"openai": "^6.48.0",
|
"openai": "^6.48.0",
|
||||||
|
"pino": "^10.3.1",
|
||||||
|
"pino-pretty": "^13.1.3",
|
||||||
"redis": "^6.1.0",
|
"redis": "^6.1.0",
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^10.0.0",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- 验收测试表
|
||||||
|
CREATE TABLE IF NOT EXISTS "AcceptanceTest" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"orgId" TEXT NOT NULL,
|
||||||
|
"verifierName" TEXT NOT NULL,
|
||||||
|
"results" JSONB NOT NULL,
|
||||||
|
"remarks" JSONB NOT NULL,
|
||||||
|
"conclusionName" TEXT,
|
||||||
|
"conclusionDate" TEXT,
|
||||||
|
"conclusionResult" TEXT,
|
||||||
|
"conclusionIssues" TEXT,
|
||||||
|
"screenshots" JSONB,
|
||||||
|
"signature1" TEXT,
|
||||||
|
"signature2" TEXT,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'DRAFT',
|
||||||
|
"submittedAt" TIMESTAMP(3),
|
||||||
|
"createdBy" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "AcceptanceTest_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 唯一约束:同一组织下验收人姓名唯一
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "AcceptanceTest_orgId_verifierName_key" ON "AcceptanceTest"("orgId", "verifierName");
|
||||||
|
|
||||||
|
-- 索引
|
||||||
|
CREATE INDEX IF NOT EXISTS "AcceptanceTest_orgId_status_idx" ON "AcceptanceTest"("orgId", "status");
|
||||||
@@ -175,6 +175,7 @@ model Organization {
|
|||||||
shifts Shift[]
|
shifts Shift[]
|
||||||
shiftAssignments ShiftAssignment[]
|
shiftAssignments ShiftAssignment[]
|
||||||
leaveRecords LeaveRecord[]
|
leaveRecords LeaveRecord[]
|
||||||
|
leaveRequests LeaveRequest[]
|
||||||
calendarEvents CalendarEvent[]
|
calendarEvents CalendarEvent[]
|
||||||
consultations Consultation[]
|
consultations Consultation[]
|
||||||
workProcesses WorkProcess[]
|
workProcesses WorkProcess[]
|
||||||
@@ -263,6 +264,7 @@ model Employee {
|
|||||||
specialDeductionRecords SpecialDeductionRecord[]
|
specialDeductionRecords SpecialDeductionRecord[]
|
||||||
shiftAssignments ShiftAssignment[]
|
shiftAssignments ShiftAssignment[]
|
||||||
leaveRecords LeaveRecord[]
|
leaveRecords LeaveRecord[]
|
||||||
|
leaveRequests LeaveRequest[]
|
||||||
calendarEvents CalendarEvent[]
|
calendarEvents CalendarEvent[]
|
||||||
workProcesses WorkProcess[]
|
workProcesses WorkProcess[]
|
||||||
specialStatuses EmployeeSpecialStatus[]
|
specialStatuses EmployeeSpecialStatus[]
|
||||||
@@ -1162,6 +1164,33 @@ model LeaveRecord {
|
|||||||
@@index([orgId, startDate])
|
@@index([orgId, startDate])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 休假审批流 ==========
|
||||||
|
|
||||||
|
model LeaveRequest {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
orgId String
|
||||||
|
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
|
employeeId String
|
||||||
|
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
||||||
|
leaveType String // SICK=病假 / PERSONAL=事假 / ANNUAL=年假 / MATERNITY=产假 / OTHER=其他
|
||||||
|
startDate DateTime
|
||||||
|
endDate DateTime
|
||||||
|
days Float // 请假天数
|
||||||
|
reason String?
|
||||||
|
attachment String? // 附件URL(如病假条)
|
||||||
|
status String @default("PENDING") // PENDING=待审批 / APPROVED=已批准 / REJECTED=已驳回 / CANCELLED=已撤回
|
||||||
|
approverId String? // 审批人ID
|
||||||
|
approvedAt DateTime?
|
||||||
|
approveRemark String? // 审批意见
|
||||||
|
createdBy String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([orgId, employeeId])
|
||||||
|
@@index([orgId, status])
|
||||||
|
@@index([orgId, startDate])
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 自定义日历事件 ==========
|
// ========== 自定义日历事件 ==========
|
||||||
|
|
||||||
model CalendarEvent {
|
model CalendarEvent {
|
||||||
@@ -1311,3 +1340,27 @@ model EmployeeSpecialStatus {
|
|||||||
@@index([orgId, type])
|
@@index([orgId, type])
|
||||||
@@index([employeeId])
|
@@index([employeeId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 验收测试 ==========
|
||||||
|
model AcceptanceTest {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
orgId String
|
||||||
|
verifierName String // 验收人姓名
|
||||||
|
results Json // { "1.1": "pass", "1.2": "fail", ... }
|
||||||
|
remarks Json // { "1.1": "备注内容", ... }
|
||||||
|
conclusionName String? // 结论验收人
|
||||||
|
conclusionDate String? // 结论日期
|
||||||
|
conclusionResult String? // pass | conditional | fail — 系统自动计算
|
||||||
|
conclusionIssues String? // 遗留问题(系统自动汇总)
|
||||||
|
screenshots Json? // { "0-0": "data:image/png;base64,...", ... }
|
||||||
|
signature1 String? // 验收人签名 dataURL
|
||||||
|
signature2 String? // 项目经理签名 dataURL
|
||||||
|
status String @default("DRAFT") // DRAFT | SUBMITTED
|
||||||
|
submittedAt DateTime?
|
||||||
|
createdBy String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([orgId, verifierName])
|
||||||
|
@@index([orgId, status])
|
||||||
|
}
|
||||||
|
|||||||
+6
-1
@@ -5,6 +5,7 @@ import morgan from 'morgan'
|
|||||||
import compression from 'compression'
|
import compression from 'compression'
|
||||||
import { errorHandler } from './middleware/errorHandler'
|
import { errorHandler } from './middleware/errorHandler'
|
||||||
import { apiLimiter } from './middleware/rateLimit'
|
import { apiLimiter } from './middleware/rateLimit'
|
||||||
|
import logger from './lib/logger'
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
|
|
||||||
@@ -62,6 +63,8 @@ import workProcessRoutes from './routes/work-process.routes'
|
|||||||
import enterpriseTemplateRoutes from './routes/enterprise-template.routes'
|
import enterpriseTemplateRoutes from './routes/enterprise-template.routes'
|
||||||
import specialStatusRoutes from './routes/special-status.routes'
|
import specialStatusRoutes from './routes/special-status.routes'
|
||||||
import companyFileRoutes from './routes/company-file.routes'
|
import companyFileRoutes from './routes/company-file.routes'
|
||||||
|
import acceptanceTestRoutes from './routes/acceptance-test.routes'
|
||||||
|
import leaveRoutes from './routes/leave.routes'
|
||||||
app.use('/api/v1/auth', authRoutes)
|
app.use('/api/v1/auth', authRoutes)
|
||||||
app.use('/api/v1/dashboard', dashboardRoutes)
|
app.use('/api/v1/dashboard', dashboardRoutes)
|
||||||
app.use('/api/v1/employees', employeeRoutes)
|
app.use('/api/v1/employees', employeeRoutes)
|
||||||
@@ -88,13 +91,15 @@ app.use('/api/v1/work-processes', workProcessRoutes)
|
|||||||
app.use('/api/v1/enterprise-templates', enterpriseTemplateRoutes)
|
app.use('/api/v1/enterprise-templates', enterpriseTemplateRoutes)
|
||||||
app.use('/api/v1/special-statuses', specialStatusRoutes)
|
app.use('/api/v1/special-statuses', specialStatusRoutes)
|
||||||
app.use('/api/v1/company-files', companyFileRoutes)
|
app.use('/api/v1/company-files', companyFileRoutes)
|
||||||
|
app.use('/api/v1/acceptance-tests', acceptanceTestRoutes)
|
||||||
|
app.use('/api/v1/leaves', leaveRoutes)
|
||||||
|
|
||||||
app.use(errorHandler)
|
app.use(errorHandler)
|
||||||
|
|
||||||
// RAG 知识库自动初始化(异步,不阻塞启动)
|
// RAG 知识库自动初始化(异步,不阻塞启动)
|
||||||
import { seedKnowledgeBase } from './services/rag.service'
|
import { seedKnowledgeBase } from './services/rag.service'
|
||||||
seedKnowledgeBase().catch((err) => {
|
seedKnowledgeBase().catch((err) => {
|
||||||
console.warn('[RAG] 知识库初始化失败,AI 问答将不使用 RAG 检索:', err?.message || err)
|
logger.warn({ err }, 'RAG 知识库初始化失败,AI 问答将不使用 RAG 检索')
|
||||||
})
|
})
|
||||||
|
|
||||||
export default app
|
export default app
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import app from './app'
|
import app from './app'
|
||||||
import { validateEnv } from './lib/config'
|
import { validateEnv } from './lib/config'
|
||||||
|
import logger from './lib/logger'
|
||||||
|
|
||||||
validateEnv()
|
validateEnv()
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT) || 3000
|
const PORT = Number(process.env.PORT) || 3000
|
||||||
|
|
||||||
app.listen(PORT, '::', () => {
|
app.listen(PORT, '::', () => {
|
||||||
console.log(`Server running on http://[::]:${PORT}`)
|
logger.info(`Server running on http://[::]:${PORT}`)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
* 功能:存储、验证、过期清理、失败次数限制
|
* 功能:存储、验证、过期清理、失败次数限制
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import logger from './logger'
|
||||||
|
|
||||||
interface CodeEntry {
|
interface CodeEntry {
|
||||||
code: string
|
code: string
|
||||||
expiresAt: number
|
expiresAt: number
|
||||||
@@ -28,11 +30,11 @@ async function getRedis() {
|
|||||||
try {
|
try {
|
||||||
const { createClient } = await import('redis')
|
const { createClient } = await import('redis')
|
||||||
redisClient = createClient({ url: REDIS_URL })
|
redisClient = createClient({ url: REDIS_URL })
|
||||||
redisClient.on('error', (err: any) => console.error('[Redis] error:', err))
|
redisClient.on('error', (err: any) => logger.error({ err }, 'Redis error'))
|
||||||
await redisClient.connect()
|
await redisClient.connect()
|
||||||
console.log('[Redis] 验证码存储已连接')
|
logger.info('Redis 验证码存储已连接')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[Redis] 连接失败,降级为内存存储:', err)
|
logger.warn({ err }, 'Redis 连接失败,降级为内存存储')
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,7 +61,7 @@ export async function setCode(key: string, code: string, ttlMs: number = 5 * 60
|
|||||||
await redis.set(`${KEY_PREFIX}${key}`, JSON.stringify(entry), { PX: ttlMs })
|
await redis.set(`${KEY_PREFIX}${key}`, JSON.stringify(entry), { PX: ttlMs })
|
||||||
return
|
return
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[Redis] set 失败,降级内存:', err)
|
logger.warn({ err }, 'Redis set 失败,降级内存')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
memoryStore.set(key, entry)
|
memoryStore.set(key, entry)
|
||||||
@@ -81,7 +83,7 @@ export async function getCode(key: string): Promise<CodeEntry | null> {
|
|||||||
}
|
}
|
||||||
return entry
|
return entry
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[Redis] get 失败,降级内存:', err)
|
logger.warn({ err }, 'Redis get 失败,降级内存')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +110,7 @@ export async function updateCode(key: string, updates: Partial<CodeEntry>): Prom
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[Redis] update 失败,降级内存:', err)
|
logger.warn({ err }, 'Redis update 失败,降级内存')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +130,7 @@ export async function deleteCode(key: string): Promise<void> {
|
|||||||
await redis.del(`${KEY_PREFIX}${key}`)
|
await redis.del(`${KEY_PREFIX}${key}`)
|
||||||
return
|
return
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[Redis] del 失败,降级内存:', err)
|
logger.warn({ err }, 'Redis del 失败,降级内存')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
memoryStore.delete(key)
|
memoryStore.delete(key)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import logger from './logger'
|
||||||
|
|
||||||
const required = ['JWT_SECRET', 'JWT_REFRESH_SECRET', 'ENCRYPTION_KEY']
|
const required = ['JWT_SECRET', 'JWT_REFRESH_SECRET', 'ENCRYPTION_KEY']
|
||||||
const defaults: Record<string, string> = {
|
const defaults: Record<string, string> = {
|
||||||
JWT_SECRET: 'dev-secret',
|
JWT_SECRET: 'dev-secret',
|
||||||
@@ -13,10 +15,10 @@ export function validateEnv(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (missing.length > 0 && process.env.NODE_ENV === 'production') {
|
if (missing.length > 0 && process.env.NODE_ENV === 'production') {
|
||||||
console.error(`[FATAL] 以下环境变量未设置或使用了默认值,生产环境禁止启动: ${missing.join(', ')}`)
|
logger.fatal({ missing }, '以下环境变量未设置或使用了默认值,生产环境禁止启动')
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
console.warn(`[WARN] 以下环境变量使用了默认值,仅限开发环境: ${missing.join(', ')}`)
|
logger.warn({ missing }, '以下环境变量使用了默认值,仅限开发环境')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import pino from 'pino'
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV !== 'production'
|
||||||
|
|
||||||
|
const logger = pino({
|
||||||
|
level: process.env.LOG_LEVEL || (isDev ? 'debug' : 'info'),
|
||||||
|
transport: isDev
|
||||||
|
? {
|
||||||
|
target: 'pino-pretty',
|
||||||
|
options: {
|
||||||
|
colorize: true,
|
||||||
|
translateTime: 'HH:MM:ss',
|
||||||
|
ignore: 'pid,hostname',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
redact: {
|
||||||
|
paths: ['req.headers.authorization', 'req.headers.cookie', '*.password', '*.passwordHash', '*.token', '*.refreshToken'],
|
||||||
|
censor: '[REDACTED]',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default logger
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* 统一分页参数解析,全局限制 pageSize ≤ 200,防止过量数据查询
|
||||||
|
*/
|
||||||
|
export function parsePagination(query: Record<string, any>): { page: number; pageSize: number } {
|
||||||
|
const page = Math.max(1, parseInt(query.page as string) || 1)
|
||||||
|
const pageSize = Math.min(Math.max(1, parseInt(query.pageSize as string) || 20), 200)
|
||||||
|
return { page, pageSize }
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AuthRequest } from './auth'
|
import { AuthRequest } from './auth'
|
||||||
import prisma from '../lib/prisma'
|
import prisma from '../lib/prisma'
|
||||||
|
import logger from '../lib/logger'
|
||||||
|
|
||||||
export async function auditLog(
|
export async function auditLog(
|
||||||
req: AuthRequest,
|
req: AuthRequest,
|
||||||
@@ -22,6 +23,6 @@ export async function auditLog(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Audit log error:', err)
|
logger.error({ err, action, entity, entityId }, 'Audit log error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Request, Response, NextFunction } from 'express'
|
|||||||
import { ZodError } from 'zod'
|
import { ZodError } from 'zod'
|
||||||
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'
|
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'
|
||||||
import { AppError } from '../lib/AppError'
|
import { AppError } from '../lib/AppError'
|
||||||
|
import logger from '../lib/logger'
|
||||||
|
|
||||||
export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) {
|
export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) {
|
||||||
if (err instanceof AppError) {
|
if (err instanceof AppError) {
|
||||||
@@ -55,7 +56,7 @@ export function errorHandler(err: unknown, _req: Request, res: Response, _next:
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error('Unhandled error:', err)
|
logger.error({ err }, 'Unhandled error')
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: { code: 'INTERNAL_ERROR', message: '服务器内部错误' },
|
error: { code: 'INTERNAL_ERROR', message: '服务器内部错误' },
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { Router, Response, NextFunction } from 'express'
|
||||||
|
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||||
|
import prisma from '../lib/prisma'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
// 保存(创建或更新)
|
||||||
|
router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { verifierName, results, remarks, conclusionName, conclusionDate, conclusionResult, conclusionIssues, screenshots, signature1, signature2 } = req.body
|
||||||
|
if (!verifierName || !verifierName.trim()) {
|
||||||
|
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请输入验收人姓名' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgId = req.user!.orgId
|
||||||
|
const data = {
|
||||||
|
verifierName: verifierName.trim(),
|
||||||
|
results: results || {},
|
||||||
|
remarks: remarks || {},
|
||||||
|
conclusionName: conclusionName || null,
|
||||||
|
conclusionDate: conclusionDate || null,
|
||||||
|
conclusionResult: conclusionResult || null,
|
||||||
|
conclusionIssues: conclusionIssues || null,
|
||||||
|
screenshots: screenshots || undefined,
|
||||||
|
signature1: signature1 || undefined,
|
||||||
|
signature2: signature2 || undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await prisma.acceptanceTest.upsert({
|
||||||
|
where: { orgId_verifierName: { orgId, verifierName: verifierName.trim() } },
|
||||||
|
create: { ...data, orgId, createdBy: req.user!.id },
|
||||||
|
update: data,
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({ success: true, data: record })
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取单个验收记录
|
||||||
|
router.get('/:verifierName', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const record = await prisma.acceptanceTest.findUnique({
|
||||||
|
where: {
|
||||||
|
orgId_verifierName: { orgId: req.user!.orgId, verifierName: req.params.verifierName },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!record) {
|
||||||
|
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '未找到保存记录' } })
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: record })
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 列表(所有验收记录摘要)
|
||||||
|
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const records = await prisma.acceptanceTest.findMany({
|
||||||
|
where: { orgId: req.user!.orgId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
verifierName: true,
|
||||||
|
status: true,
|
||||||
|
conclusionResult: true,
|
||||||
|
conclusionDate: true,
|
||||||
|
updatedAt: true,
|
||||||
|
results: true,
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = records.map((r: any) => {
|
||||||
|
const vals = Object.values(r.results as any)
|
||||||
|
const pass = vals.filter((v: any) => v === 'pass').length
|
||||||
|
const total = vals.length
|
||||||
|
const tested = vals.filter((v: any) => v !== 'untested').length
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
verifierName: r.verifierName,
|
||||||
|
status: r.status,
|
||||||
|
conclusionResult: r.conclusionResult,
|
||||||
|
conclusionDate: r.conclusionDate,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
testedCount: tested,
|
||||||
|
passCount: pass,
|
||||||
|
totalCount: total,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({ success: true, data: list })
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 提交验收报告
|
||||||
|
router.post('/:verifierName/submit', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { conclusionResult, conclusionIssues } = req.body
|
||||||
|
const record = await prisma.acceptanceTest.findUnique({
|
||||||
|
where: {
|
||||||
|
orgId_verifierName: { orgId: req.user!.orgId, verifierName: req.params.verifierName },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!record) {
|
||||||
|
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '请先保存再提交' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.acceptanceTest.update({
|
||||||
|
where: { id: record.id },
|
||||||
|
data: {
|
||||||
|
status: 'SUBMITTED',
|
||||||
|
submittedAt: new Date(),
|
||||||
|
conclusionResult: conclusionResult || record.conclusionResult,
|
||||||
|
conclusionIssues: conclusionIssues || record.conclusionIssues,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({ success: true, data: updated })
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
router.delete('/:verifierName', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
await prisma.acceptanceTest.delete({
|
||||||
|
where: {
|
||||||
|
orgId_verifierName: { orgId: req.user!.orgId, verifierName: req.params.verifierName },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
res.json({ success: true })
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -2,7 +2,6 @@ import { Router, Response, NextFunction } from 'express'
|
|||||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import {
|
import {
|
||||||
createAttendanceConfirmation,
|
|
||||||
batchCreateAttendanceConfirmations,
|
batchCreateAttendanceConfirmations,
|
||||||
getAttendanceConfirmations,
|
getAttendanceConfirmations,
|
||||||
confirmAttendance,
|
confirmAttendance,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Router, Response, NextFunction } from 'express'
|
import { Router, Response, NextFunction } from 'express'
|
||||||
import prisma from '../lib/prisma'
|
import prisma from '../lib/prisma'
|
||||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||||
import { getDashboardData, getMonthlyCalendar, getCostAnalysis, getComplianceScore, getHealthCheck, saveHealthCheckReport, getHealthCheckHistory, getAnnualValueReport, saveAnnualValueReport, getAnnualValueReportHistory } from '../services/risk.service'
|
import { getDashboardData, getMonthlyCalendar, getCostAnalysis, getHealthCheck, saveHealthCheckReport, getHealthCheckHistory, getAnnualValueReport, saveAnnualValueReport, getAnnualValueReportHistory } from '../services/risk.service'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Router, Response, NextFunction } from 'express'
|
import { Router, Response, NextFunction } from 'express'
|
||||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||||
import prisma from '../lib/prisma'
|
import prisma from '../lib/prisma'
|
||||||
import { renderTemplate } from '../services/template.service'
|
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
|
|||||||
@@ -255,7 +255,6 @@ router.get('/roster', authMiddleware, async (req: AuthRequest, res: Response, ne
|
|||||||
const search = req.query.search as string | undefined
|
const search = req.query.search as string | undefined
|
||||||
const status = req.query.status as string | undefined
|
const status = req.query.status as string | undefined
|
||||||
const department = req.query.department as string | undefined
|
const department = req.query.department as string | undefined
|
||||||
const contractStatus = req.query.contractStatus as string | undefined
|
|
||||||
|
|
||||||
const where: any = { orgId }
|
const where: any = { orgId }
|
||||||
if (department) where.department = department
|
if (department) where.department = department
|
||||||
|
|||||||
@@ -324,7 +324,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
|
|||||||
})
|
})
|
||||||
|
|
||||||
result.employees++
|
result.employees++
|
||||||
result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'success', message: '导入成功' })
|
result.details.push({ sheet: '员工信息', row: i + 2, name, employeeId: emp.id, status: 'success', message: '导入成功' })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const msg = e?.message || ''
|
const msg = e?.message || ''
|
||||||
if (msg.includes('Unique constraint')) {
|
if (msg.includes('Unique constraint')) {
|
||||||
@@ -720,7 +720,6 @@ router.post('/payroll', authMiddleware, upload.single('file'), async (req: AuthR
|
|||||||
try {
|
try {
|
||||||
if (!req.file) return res.status(400).json({ success: false, message: '请上传文件' })
|
if (!req.file) return res.status(400).json({ success: false, message: '请上传文件' })
|
||||||
const orgId = req.user!.orgId
|
const orgId = req.user!.orgId
|
||||||
const userId = req.user!.id
|
|
||||||
const batchId = req.body.batchId as string
|
const batchId = req.body.batchId as string
|
||||||
if (!batchId) return res.status(400).json({ success: false, message: '缺少批次ID' })
|
if (!batchId) return res.status(400).json({ success: false, message: '缺少批次ID' })
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { Router, Response, NextFunction } from 'express'
|
||||||
|
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||||
|
import prisma from '../lib/prisma'
|
||||||
|
import { parsePagination } from '../lib/pagination'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
// 休假申请列表
|
||||||
|
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const orgId = req.user!.orgId
|
||||||
|
const { status, leaveType, employeeId } = req.query
|
||||||
|
const { page, pageSize } = parsePagination(req.query)
|
||||||
|
|
||||||
|
const where: any = { orgId }
|
||||||
|
if (status) where.status = status
|
||||||
|
if (leaveType) where.leaveType = leaveType
|
||||||
|
if (employeeId) where.employeeId = employeeId
|
||||||
|
|
||||||
|
const total = await prisma.leaveRequest.count({ where })
|
||||||
|
const list = await prisma.leaveRequest.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
employee: { select: { id: true, name: true, department: true, position: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({ success: true, data: { list, total, page, pageSize } })
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建休假申请
|
||||||
|
router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const orgId = req.user!.orgId
|
||||||
|
const { employeeId, leaveType, startDate, endDate, days, reason, attachment } = req.body
|
||||||
|
|
||||||
|
if (!employeeId || !leaveType || !startDate || !endDate) {
|
||||||
|
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少必填字段' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await prisma.leaveRequest.create({
|
||||||
|
data: {
|
||||||
|
orgId,
|
||||||
|
employeeId,
|
||||||
|
leaveType,
|
||||||
|
startDate: new Date(startDate),
|
||||||
|
endDate: new Date(endDate),
|
||||||
|
days: days || 1,
|
||||||
|
reason: reason || null,
|
||||||
|
attachment: attachment || null,
|
||||||
|
createdBy: req.user!.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
employee: { select: { id: true, name: true, department: true, position: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({ success: true, data: record })
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 审批(批准/驳回)
|
||||||
|
router.post('/:id/approve', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const { action, remark } = req.body // action: 'APPROVED' | 'REJECTED'
|
||||||
|
const orgId = req.user!.orgId
|
||||||
|
|
||||||
|
if (!['APPROVED', 'REJECTED'].includes(action)) {
|
||||||
|
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '无效的审批操作' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.leaveRequest.findFirst({ where: { id, orgId } })
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '申请不存在' } })
|
||||||
|
}
|
||||||
|
if (existing.status !== 'PENDING') {
|
||||||
|
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '该申请已处理' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.leaveRequest.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: action,
|
||||||
|
approverId: req.user!.id,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
approveRemark: remark || null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
employee: { select: { id: true, name: true, department: true, position: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 如果批准,自动创建 LeaveRecord
|
||||||
|
if (action === 'APPROVED') {
|
||||||
|
await prisma.leaveRecord.create({
|
||||||
|
data: {
|
||||||
|
orgId,
|
||||||
|
employeeId: existing.employeeId,
|
||||||
|
leaveType: existing.leaveType,
|
||||||
|
startDate: existing.startDate,
|
||||||
|
endDate: existing.endDate,
|
||||||
|
days: existing.days,
|
||||||
|
reason: existing.reason || '',
|
||||||
|
remark: `休假审批通过(申请ID: ${id})`,
|
||||||
|
createdBy: req.user!.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: updated })
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 撤回申请
|
||||||
|
router.post('/:id/cancel', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const orgId = req.user!.orgId
|
||||||
|
|
||||||
|
const existing = await prisma.leaveRequest.findFirst({ where: { id, orgId } })
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '申请不存在' } })
|
||||||
|
}
|
||||||
|
if (existing.status !== 'PENDING') {
|
||||||
|
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已处理的申请不可撤回' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.leaveRequest.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'CANCELLED' },
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({ success: true, data: updated })
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 删除申请(仅待审批状态可删)
|
||||||
|
router.delete('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const orgId = req.user!.orgId
|
||||||
|
|
||||||
|
const existing = await prisma.leaveRequest.findFirst({ where: { id, orgId } })
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '申请不存在' } })
|
||||||
|
}
|
||||||
|
if (existing.status !== 'PENDING') {
|
||||||
|
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已处理的申请不可删除' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.leaveRequest.delete({ where: { id } })
|
||||||
|
res.json({ success: true })
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 统计
|
||||||
|
router.get('/stats', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const orgId = req.user!.orgId
|
||||||
|
const { month } = req.query
|
||||||
|
|
||||||
|
const where: any = { orgId }
|
||||||
|
if (month) {
|
||||||
|
const start = new Date(`${month}-01`)
|
||||||
|
const end = new Date(`${month}-31T23:59:59`)
|
||||||
|
where.startDate = { gte: start, lte: end }
|
||||||
|
}
|
||||||
|
|
||||||
|
const [pending, approved, rejected, cancelled, total] = await Promise.all([
|
||||||
|
prisma.leaveRequest.count({ where: { ...where, status: 'PENDING' } }),
|
||||||
|
prisma.leaveRequest.count({ where: { ...where, status: 'APPROVED' } }),
|
||||||
|
prisma.leaveRequest.count({ where: { ...where, status: 'REJECTED' } }),
|
||||||
|
prisma.leaveRequest.count({ where: { ...where, status: 'CANCELLED' } }),
|
||||||
|
prisma.leaveRequest.count({ where }),
|
||||||
|
])
|
||||||
|
|
||||||
|
res.json({ success: true, data: { pending, approved, rejected, cancelled, total } })
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -132,9 +132,13 @@ router.post('/check-contracts', async (req: AuthRequest, res: Response, next: Ne
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 测试通知渠道
|
// 测试通知渠道
|
||||||
|
const testChannelSchema = z.object({
|
||||||
|
channel: z.enum(['wechat', 'email']),
|
||||||
|
})
|
||||||
|
|
||||||
router.post('/test', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
router.post('/test', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const { channel } = req.body as { channel: 'wechat' | 'email' }
|
const { channel } = testChannelSchema.parse(req.body)
|
||||||
const setting = await prisma.notificationSetting.findUnique({ where: { orgId: req.user!.orgId } })
|
const setting = await prisma.notificationSetting.findUnique({ where: { orgId: req.user!.orgId } })
|
||||||
if (!setting) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '通知设置不存在' } })
|
if (!setting) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '通知设置不存在' } })
|
||||||
|
|
||||||
@@ -154,12 +158,9 @@ router.post('/test', async (req: AuthRequest, res: Response, next: NextFunction)
|
|||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
res.json({ success: false, error: { code: 'TEST_FAILED', message: `发送失败: ${e?.message || '网络错误'}` } })
|
res.json({ success: false, error: { code: 'TEST_FAILED', message: `发送失败: ${e?.message || '网络错误'}` } })
|
||||||
}
|
}
|
||||||
} else if (channel === 'email') {
|
|
||||||
if (!setting.email) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '未配置通知邮箱' } })
|
|
||||||
// 邮件发送(开发阶段仅返回成功)
|
|
||||||
res.json({ success: true, data: { message: `测试邮件已发送到 ${setting.email}` } })
|
|
||||||
} else {
|
} else {
|
||||||
res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '不支持的通知渠道' } })
|
if (!setting.email) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '未配置通知邮箱' } })
|
||||||
|
res.json({ success: true, data: { message: `测试邮件已发送到 ${setting.email}` } })
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err)
|
next(err)
|
||||||
|
|||||||
@@ -504,7 +504,45 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
res.json({ success: true, data: updated })
|
res.json({ success: true, data: updated, taxBreakdown: calcResult.taxBreakdown })
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取条目个税计算明细
|
||||||
|
router.get('/batches/:batchId/entries/:employeeId/tax-detail', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { batchId, employeeId } = req.params
|
||||||
|
const orgId = req.user!.orgId
|
||||||
|
|
||||||
|
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
|
||||||
|
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
|
||||||
|
|
||||||
|
const entry = await prisma.batchEntry.findUnique({
|
||||||
|
where: { batchId_employeeId: { batchId, employeeId } },
|
||||||
|
})
|
||||||
|
if (!entry) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '条目不存在' } })
|
||||||
|
|
||||||
|
const inputs = {
|
||||||
|
baseSalary: entry.baseSalary,
|
||||||
|
overtimePay: entry.overtimePay,
|
||||||
|
allowance: entry.allowance,
|
||||||
|
deduction: entry.deduction,
|
||||||
|
bonus: entry.bonus,
|
||||||
|
}
|
||||||
|
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type)
|
||||||
|
const taxBreakdown = calcResult.taxBreakdown || {}
|
||||||
|
// 加入社保公积金系统计算值 vs 实际值对比
|
||||||
|
taxBreakdown.systemSocialEmp = calcResult.systemSocialEmp
|
||||||
|
taxBreakdown.systemSocialOrg = calcResult.systemSocialOrg
|
||||||
|
taxBreakdown.systemHousingEmp = calcResult.systemHousingEmp
|
||||||
|
taxBreakdown.systemHousingOrg = calcResult.systemHousingOrg
|
||||||
|
taxBreakdown.actualSocialEmp = entry.socialEmp
|
||||||
|
taxBreakdown.actualSocialOrg = entry.socialOrg
|
||||||
|
taxBreakdown.actualHousingEmp = entry.housingEmp
|
||||||
|
taxBreakdown.actualHousingOrg = entry.housingOrg
|
||||||
|
res.json({ success: true, data: taxBreakdown })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err)
|
next(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import prisma from '../lib/prisma'
|
import prisma from '../lib/prisma'
|
||||||
import { AuthRequest, authMiddleware, platformAdminMiddleware } from '../middleware/auth'
|
import { AuthRequest, authMiddleware, platformAdminMiddleware } from '../middleware/auth'
|
||||||
import { loginLimiter } from '../middleware/rateLimit'
|
import { parsePagination } from '../lib/pagination'
|
||||||
|
import { createOrgSchema, updateOrgSchema, updateOrgAdminSchema, createPlatformAdminSchema } from '../schemas/platform.schema'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@@ -88,8 +89,7 @@ router.get('/dashboard', async (_req: AuthRequest, res, next) => {
|
|||||||
*/
|
*/
|
||||||
router.get('/orgs', async (req: AuthRequest, res, next) => {
|
router.get('/orgs', async (req: AuthRequest, res, next) => {
|
||||||
try {
|
try {
|
||||||
const page = parseInt(req.query.page as string) || 1
|
const { page, pageSize } = parsePagination(req.query)
|
||||||
const pageSize = parseInt(req.query.pageSize as string) || 20
|
|
||||||
const search = (req.query.search as string) || ''
|
const search = (req.query.search as string) || ''
|
||||||
const planFilter = (req.query.plan as string) || ''
|
const planFilter = (req.query.plan as string) || ''
|
||||||
|
|
||||||
@@ -154,19 +154,7 @@ router.post('/orgs', async (req: AuthRequest, res, next) => {
|
|||||||
const {
|
const {
|
||||||
name, plan, maxEmployees, city, contactName, contactPhone,
|
name, plan, maxEmployees, city, contactName, contactPhone,
|
||||||
adminName, adminPhone, adminPassword,
|
adminName, adminPhone, adminPassword,
|
||||||
} = req.body as {
|
} = createOrgSchema.parse(req.body)
|
||||||
name: string; plan?: string; maxEmployees?: number
|
|
||||||
city?: string; contactName?: string; contactPhone?: string
|
|
||||||
adminName?: string; adminPhone: string; adminPassword: string
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!name || !adminPhone || !adminPassword) {
|
|
||||||
return res.status(400).json({ success: false, error: { code: 'VALIDATION', message: '企业名称、管理员手机号、密码不能为空' } })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (adminPassword.length < 8) {
|
|
||||||
return res.status(400).json({ success: false, error: { code: 'VALIDATION', message: '密码至少8位' } })
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await prisma.user.findUnique({ where: { phone: adminPhone } })
|
const existing = await prisma.user.findUnique({ where: { phone: adminPhone } })
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -244,10 +232,7 @@ router.get('/orgs/:id', async (req: AuthRequest, res, next) => {
|
|||||||
*/
|
*/
|
||||||
router.put('/orgs/:id', async (req: AuthRequest, res, next) => {
|
router.put('/orgs/:id', async (req: AuthRequest, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { name, plan, maxEmployees, city, contactName, contactPhone } = req.body as {
|
const { name, plan, maxEmployees, city, contactName, contactPhone } = updateOrgSchema.parse(req.body)
|
||||||
name?: string; plan?: string; maxEmployees?: number;
|
|
||||||
city?: string; contactName?: string; contactPhone?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateData: any = {}
|
const updateData: any = {}
|
||||||
if (name) updateData.name = name
|
if (name) updateData.name = name
|
||||||
@@ -274,9 +259,7 @@ router.put('/orgs/:id', async (req: AuthRequest, res, next) => {
|
|||||||
*/
|
*/
|
||||||
router.put('/orgs/:id/admin', async (req: AuthRequest, res, next) => {
|
router.put('/orgs/:id/admin', async (req: AuthRequest, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { adminName, adminPhone, adminPassword } = req.body as {
|
const { adminName, adminPhone, adminPassword } = updateOrgAdminSchema.parse(req.body)
|
||||||
adminName?: string; adminPhone?: string; adminPassword?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// 找到该企业的 ADMIN 角色用户(第一个管理员)
|
// 找到该企业的 ADMIN 角色用户(第一个管理员)
|
||||||
const admin = await prisma.user.findFirst({
|
const admin = await prisma.user.findFirst({
|
||||||
@@ -335,8 +318,7 @@ router.delete('/orgs/:id', async (req: AuthRequest, res, next) => {
|
|||||||
*/
|
*/
|
||||||
router.get('/users', async (req: AuthRequest, res, next) => {
|
router.get('/users', async (req: AuthRequest, res, next) => {
|
||||||
try {
|
try {
|
||||||
const page = parseInt(req.query.page as string) || 1
|
const { page, pageSize } = parsePagination(req.query)
|
||||||
const pageSize = parseInt(req.query.pageSize as string) || 20
|
|
||||||
const search = (req.query.search as string) || ''
|
const search = (req.query.search as string) || ''
|
||||||
const orgId = (req.query.orgId as string) || ''
|
const orgId = (req.query.orgId as string) || ''
|
||||||
|
|
||||||
@@ -432,11 +414,7 @@ router.get('/admins', async (_req: AuthRequest, res, next) => {
|
|||||||
*/
|
*/
|
||||||
router.post('/admins', async (req: AuthRequest, res, next) => {
|
router.post('/admins', async (req: AuthRequest, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { name, phone, password } = req.body as { name: string; phone: string; password: string }
|
const { name, phone, password } = createPlatformAdminSchema.parse(req.body)
|
||||||
|
|
||||||
if (!name || !phone || !password) {
|
|
||||||
return res.status(400).json({ success: false, error: { code: 'VALIDATION', message: '姓名、手机号、密码不能为空' } })
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await prisma.user.findUnique({ where: { phone } })
|
const existing = await prisma.user.findUnique({ where: { phone } })
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|||||||
@@ -838,4 +838,64 @@ router.post('/resignation/:id/withdraw', portalAuth, async (req: any, res, next)
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ========== 员工端:休假申请 ==========
|
||||||
|
// 查看自己的休假申请列表
|
||||||
|
router.get('/leaves', portalAuth, async (req: any, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id: employeeId, orgId } = req.employee
|
||||||
|
const list = await prisma.leaveRequest.findMany({
|
||||||
|
where: { employeeId, orgId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
res.json({ success: true, data: list })
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 提交休假申请
|
||||||
|
router.post('/leaves', portalAuth, async (req: any, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id: employeeId, orgId } = req.employee
|
||||||
|
const { leaveType, startDate, endDate, days, reason } = req.body
|
||||||
|
|
||||||
|
if (!leaveType || !startDate || !endDate) {
|
||||||
|
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少必填字段' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await prisma.leaveRequest.create({
|
||||||
|
data: {
|
||||||
|
orgId,
|
||||||
|
employeeId,
|
||||||
|
leaveType,
|
||||||
|
startDate: new Date(startDate),
|
||||||
|
endDate: new Date(endDate),
|
||||||
|
days: days || 1,
|
||||||
|
reason: reason || null,
|
||||||
|
createdBy: employeeId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
res.json({ success: true, data: record })
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 撤回休假申请(仅待审批可撤回)
|
||||||
|
router.post('/leaves/:id/cancel', portalAuth, async (req: any, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id: employeeId, orgId } = req.employee
|
||||||
|
const record = await prisma.leaveRequest.findFirst({
|
||||||
|
where: { id: req.params.id, employeeId, orgId },
|
||||||
|
})
|
||||||
|
if (!record) {
|
||||||
|
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '申请不存在' } })
|
||||||
|
}
|
||||||
|
if (record.status !== 'PENDING') {
|
||||||
|
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已处理的申请不可撤回' } })
|
||||||
|
}
|
||||||
|
const updated = await prisma.leaveRequest.update({
|
||||||
|
where: { id: record.id },
|
||||||
|
data: { status: 'CANCELLED' },
|
||||||
|
})
|
||||||
|
res.json({ success: true, data: updated })
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { Router, Response, NextFunction } from 'express'
|
import { Router, Response, NextFunction } from 'express'
|
||||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||||
import prisma from '../lib/prisma'
|
import prisma from '../lib/prisma'
|
||||||
|
import { parsePagination } from '../lib/pagination'
|
||||||
import {
|
import {
|
||||||
createSpecialStatus,
|
createSpecialStatus,
|
||||||
updateSpecialStatus,
|
updateSpecialStatus,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
STATUS_TYPES,
|
STATUS_TYPES,
|
||||||
getAlertLevel,
|
getAlertLevel,
|
||||||
} from '../services/special-status.service'
|
} from '../services/special-status.service'
|
||||||
|
import { createSpecialStatusSchema, updateSpecialStatusSchema } from '../schemas/special-status.schema'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@@ -22,8 +24,7 @@ const router = Router()
|
|||||||
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const orgId = req.user!.orgId
|
const orgId = req.user!.orgId
|
||||||
const page = parseInt(req.query.page as string) || 1
|
const { page, pageSize } = parsePagination(req.query)
|
||||||
const pageSize = parseInt(req.query.pageSize as string) || 20
|
|
||||||
const type = (req.query.type as string) || ''
|
const type = (req.query.type as string) || ''
|
||||||
const status = (req.query.status as string) || ''
|
const status = (req.query.status as string) || ''
|
||||||
const search = (req.query.search as string) || ''
|
const search = (req.query.search as string) || ''
|
||||||
@@ -97,7 +98,8 @@ router.get('/:id', authMiddleware, async (req: AuthRequest, res: Response, next:
|
|||||||
*/
|
*/
|
||||||
router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const record = await createSpecialStatus(req.user!.orgId, req.user!.id, req.body)
|
const data = createSpecialStatusSchema.parse(req.body)
|
||||||
|
const record = await createSpecialStatus(req.user!.orgId, req.user!.id, data)
|
||||||
res.json({ success: true, data: record })
|
res.json({ success: true, data: record })
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.code) {
|
if (err.code) {
|
||||||
@@ -112,7 +114,8 @@ router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: N
|
|||||||
*/
|
*/
|
||||||
router.put('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
router.put('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const record = await updateSpecialStatus(req.user!.orgId, req.user!.id, req.params.id, req.body)
|
const data = updateSpecialStatusSchema.parse(req.body)
|
||||||
|
const record = await updateSpecialStatus(req.user!.orgId, req.user!.id, req.params.id, data)
|
||||||
res.json({ success: true, data: record })
|
res.json({ success: true, data: record })
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.code) {
|
if (err.code) {
|
||||||
@@ -167,7 +170,6 @@ router.get('/stats/overview', authMiddleware, async (req: AuthRequest, res: Resp
|
|||||||
])
|
])
|
||||||
|
|
||||||
// 预警统计
|
// 预警统计
|
||||||
const now = new Date()
|
|
||||||
const soon7 = new Date()
|
const soon7 = new Date()
|
||||||
soon7.setDate(soon7.getDate() + 7)
|
soon7.setDate(soon7.getDate() + 7)
|
||||||
const soon30 = new Date()
|
const soon30 = new Date()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||||
import { auditLog } from '../middleware/auditLog'
|
import { auditLog } from '../middleware/auditLog'
|
||||||
import { terminationChecklistSchema } from '../schemas/termination.schema'
|
import { terminationChecklistSchema, resignationSchema, batchTerminatePreviewSchema, batchTerminateSchema, createTerminationDraftSchema, updateTerminationDraftSchema } from '../schemas/termination.schema'
|
||||||
import { createTermination, createResignation, revokeTermination, getTerminations, getChecklistForReason, assessRisk, batchTerminatePreview, batchTerminate, createDraft, updateDraft, submitForApproval, approveTermination, rejectTermination, executeTermination, cancelTermination, getDrafts, getTerminationDetail, getDefaultHandoverItems, validateTerminationStep } from '../services/termination.service'
|
import { createTermination, createResignation, revokeTermination, getTerminations, getChecklistForReason, assessRisk, batchTerminatePreview, batchTerminate, createDraft, updateDraft, submitForApproval, approveTermination, rejectTermination, executeTermination, cancelTermination, getDrafts, getTerminationDetail, getDefaultHandoverItems, validateTerminationStep } from '../services/termination.service'
|
||||||
import prisma from '../lib/prisma'
|
import prisma from '../lib/prisma'
|
||||||
import { createEvidence } from '../services/evidence.service'
|
import { createEvidence } from '../services/evidence.service'
|
||||||
@@ -80,10 +80,7 @@ router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
|||||||
|
|
||||||
router.post('/resignation', authMiddleware, async (req: AuthRequest, res, next) => {
|
router.post('/resignation', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { employeeId, terminationDate, resignationReason, remark } = req.body
|
const { employeeId, terminationDate, resignationReason, remark } = resignationSchema.parse(req.body)
|
||||||
if (!employeeId || !terminationDate) {
|
|
||||||
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '缺少必填字段' } })
|
|
||||||
}
|
|
||||||
const result = await createResignation(req.user!.orgId, req.user!.id, { employeeId, terminationDate, resignationReason, remark })
|
const result = await createResignation(req.user!.orgId, req.user!.id, { employeeId, terminationDate, resignationReason, remark })
|
||||||
await auditLog(req, 'RESIGN', 'EMPLOYEE', employeeId, { resignationReason })
|
await auditLog(req, 'RESIGN', 'EMPLOYEE', employeeId, { resignationReason })
|
||||||
res.json({ success: true, data: result })
|
res.json({ success: true, data: result })
|
||||||
@@ -120,12 +117,7 @@ router.delete('/:id/revoke', authMiddleware, async (req: AuthRequest, res, next)
|
|||||||
// 批量解聘预检
|
// 批量解聘预检
|
||||||
router.post('/batch/preview', authMiddleware, async (req: AuthRequest, res, next) => {
|
router.post('/batch/preview', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { items } = req.body as {
|
const { items } = batchTerminatePreviewSchema.parse(req.body)
|
||||||
items: Array<{ employeeId: string; reason: string; terminationDate: string }>
|
|
||||||
}
|
|
||||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
|
||||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 items' } })
|
|
||||||
}
|
|
||||||
const results = await batchTerminatePreview(req.user!.orgId, items)
|
const results = await batchTerminatePreview(req.user!.orgId, items)
|
||||||
res.json({ success: true, data: { total: results.length, warnings: results.filter(r => r.warnings.length > 0).length, results } })
|
res.json({ success: true, data: { total: results.length, warnings: results.filter(r => r.warnings.length > 0).length, results } })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -136,12 +128,7 @@ router.post('/batch/preview', authMiddleware, async (req: AuthRequest, res, next
|
|||||||
// 批量解聘执行
|
// 批量解聘执行
|
||||||
router.post('/batch', authMiddleware, async (req: AuthRequest, res, next) => {
|
router.post('/batch', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { items } = req.body as {
|
const { items } = batchTerminateSchema.parse(req.body)
|
||||||
items: Array<{ employeeId: string; reason: string; terminationDate: string; compensation?: number }>
|
|
||||||
}
|
|
||||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
|
||||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 items' } })
|
|
||||||
}
|
|
||||||
const result = await batchTerminate(req.user!.orgId, req.user!.id, items)
|
const result = await batchTerminate(req.user!.orgId, req.user!.id, items)
|
||||||
for (const id of result.success) {
|
for (const id of result.success) {
|
||||||
await auditLog(req, 'TERMINATE', 'EMPLOYEE', id, { batch: true })
|
await auditLog(req, 'TERMINATE', 'EMPLOYEE', id, { batch: true })
|
||||||
@@ -192,15 +179,16 @@ router.get('/handover-template', authMiddleware, async (req: AuthRequest, res) =
|
|||||||
// 创建草稿
|
// 创建草稿
|
||||||
router.post('/draft', authMiddleware, async (req: AuthRequest, res, next) => {
|
router.post('/draft', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||||
try {
|
try {
|
||||||
const result = await createDraft(req.user!.orgId, req.user!.id, req.body)
|
const data = createTerminationDraftSchema.parse(req.body)
|
||||||
const emp = await prisma.employee.findFirst({ where: { id: req.body.employeeId }, select: { name: true, department: true } })
|
const result = await createDraft(req.user!.orgId, req.user!.id, data)
|
||||||
|
const emp = await prisma.employee.findFirst({ where: { id: data.employeeId }, select: { name: true, department: true } })
|
||||||
await auditLog(req, 'CREATE_DRAFT', 'TERMINATION_RECORD', result.id, {
|
await auditLog(req, 'CREATE_DRAFT', 'TERMINATION_RECORD', result.id, {
|
||||||
employeeName: emp?.name || '',
|
employeeName: emp?.name || '',
|
||||||
department: emp?.department || '',
|
department: emp?.department || '',
|
||||||
reason: req.body.reason || '',
|
reason: data.reason || '',
|
||||||
type: req.body.type || 'TERMINATION',
|
type: data.type || 'TERMINATION',
|
||||||
terminationDate: req.body.terminationDate || '',
|
terminationDate: data.terminationDate || '',
|
||||||
compensation: req.body.compensation || 0,
|
compensation: data.compensation || 0,
|
||||||
})
|
})
|
||||||
res.json({ success: true, data: result })
|
res.json({ success: true, data: result })
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -214,7 +202,8 @@ router.post('/draft', authMiddleware, async (req: AuthRequest, res, next) => {
|
|||||||
// 更新草稿
|
// 更新草稿
|
||||||
router.put('/draft/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
router.put('/draft/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||||
try {
|
try {
|
||||||
const result = await updateDraft(req.user!.orgId, req.params.id, req.user!.id, req.body)
|
const data = updateTerminationDraftSchema.parse(req.body)
|
||||||
|
const result = await updateDraft(req.user!.orgId, req.params.id, req.user!.id, data)
|
||||||
res.json({ success: true, data: result })
|
res.json({ success: true, data: result })
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.code === 'CONFLICT' || err?.code === 'NOT_FOUND') {
|
if (err?.code === 'CONFLICT' || err?.code === 'NOT_FOUND') {
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { Router, Response, NextFunction } from 'express'
|
import { Router, Response, NextFunction } from 'express'
|
||||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||||
import prisma from '../lib/prisma'
|
import prisma from '../lib/prisma'
|
||||||
|
import { parsePagination } from '../lib/pagination'
|
||||||
import { executeWorkProcess, generateDocument, PROCESS_TYPES, PROCESS_STATUS } from '../services/work-process.service'
|
import { executeWorkProcess, generateDocument, PROCESS_TYPES, PROCESS_STATUS } from '../services/work-process.service'
|
||||||
|
import { createWorkProcessSchema, updateWorkProcessSchema } from '../schemas/work-process.schema'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
// 创建办理(含草稿)
|
// 创建办理(含草稿)
|
||||||
router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const { type, title, employeeId, formData, status = 'DRAFT', remark } = req.body
|
const data = createWorkProcessSchema.parse(req.body)
|
||||||
if (!type || !PROCESS_TYPES[type]) {
|
const { type, title, employeeId, formData, status, remark } = data
|
||||||
return res.status(400).json({ success: false, error: { code: 'INVALID_TYPE', message: '无效的流程类型' } })
|
|
||||||
}
|
|
||||||
const process = await (prisma as any).workProcess.create({
|
const process = await (prisma as any).workProcess.create({
|
||||||
data: {
|
data: {
|
||||||
orgId: req.user!.orgId,
|
orgId: req.user!.orgId,
|
||||||
@@ -33,7 +33,8 @@ router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: N
|
|||||||
// 列表查询
|
// 列表查询
|
||||||
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const { type, status, page = '1', pageSize = '20' } = req.query
|
const { type, status } = req.query
|
||||||
|
const { page, pageSize } = parsePagination(req.query)
|
||||||
const where: any = { orgId: req.user!.orgId }
|
const where: any = { orgId: req.user!.orgId }
|
||||||
if (type) where.type = type
|
if (type) where.type = type
|
||||||
if (status) where.status = status
|
if (status) where.status = status
|
||||||
@@ -42,10 +43,10 @@ router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: Ne
|
|||||||
where,
|
where,
|
||||||
include: { employee: { select: { id: true, name: true, department: true } } },
|
include: { employee: { select: { id: true, name: true, department: true } } },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
skip: (Number(page) - 1) * Number(pageSize),
|
skip: (page - 1) * pageSize,
|
||||||
take: Number(pageSize),
|
take: pageSize,
|
||||||
})
|
})
|
||||||
res.json({ success: true, data: { items, total, page: Number(page), pageSize: Number(pageSize) } })
|
res.json({ success: true, data: { items, total, page, pageSize } })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err)
|
next(err)
|
||||||
}
|
}
|
||||||
@@ -79,7 +80,7 @@ router.patch('/:id', authMiddleware, async (req: AuthRequest, res: Response, nex
|
|||||||
if (existing.status !== 'DRAFT') {
|
if (existing.status !== 'DRAFT') {
|
||||||
return res.status(400).json({ success: false, error: { code: 'NOT_DRAFT', message: '仅草稿状态可编辑' } })
|
return res.status(400).json({ success: false, error: { code: 'NOT_DRAFT', message: '仅草稿状态可编辑' } })
|
||||||
}
|
}
|
||||||
const { title, employeeId, formData, remark } = req.body
|
const { title, employeeId, formData, remark } = updateWorkProcessSchema.parse(req.body)
|
||||||
const updated = await (prisma as any).workProcess.update({
|
const updated = await (prisma as any).workProcess.update({
|
||||||
where: { id: req.params.id },
|
where: { id: req.params.id },
|
||||||
data: {
|
data: {
|
||||||
@@ -116,7 +117,7 @@ router.post('/:id/submit', authMiddleware, async (req: AuthRequest, res: Respons
|
|||||||
}
|
}
|
||||||
// 生成文书
|
// 生成文书
|
||||||
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId } })
|
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId } })
|
||||||
const doc = generateDocument(process.type, process.formData, org?.name || '')
|
const doc = await generateDocument(process.type, process.formData, org?.name || '')
|
||||||
const documents = doc.content ? [doc] : []
|
const documents = doc.content ? [doc] : []
|
||||||
const updated = await (prisma as any).workProcess.update({
|
const updated = await (prisma as any).workProcess.update({
|
||||||
where: { id: process.id },
|
where: { id: process.id },
|
||||||
@@ -151,7 +152,7 @@ router.post('/:id/approve', authMiddleware, async (req: AuthRequest, res: Respon
|
|||||||
return res.status(400).json({ success: false, error: { code: 'EXEC_FAILED', message: `执行失败:${execErr?.message || '未知错误'}` } })
|
return res.status(400).json({ success: false, error: { code: 'EXEC_FAILED', message: `执行失败:${execErr?.message || '未知错误'}` } })
|
||||||
}
|
}
|
||||||
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId } })
|
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId } })
|
||||||
const doc = generateDocument(process.type, process.formData, org?.name || '')
|
const doc = await generateDocument(process.type, process.formData, org?.name || '')
|
||||||
const documents = doc.content ? [doc] : []
|
const documents = doc.content ? [doc] : []
|
||||||
const updated = await (prisma as any).workProcess.update({
|
const updated = await (prisma as any).workProcess.update({
|
||||||
where: { id: process.id },
|
where: { id: process.id },
|
||||||
@@ -247,7 +248,7 @@ router.get('/:id/preview', authMiddleware, async (req: AuthRequest, res: Respons
|
|||||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } })
|
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } })
|
||||||
}
|
}
|
||||||
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId } })
|
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId } })
|
||||||
const doc = generateDocument(process.type, process.formData, org?.name || '')
|
const doc = await generateDocument(process.type, process.formData, org?.name || '')
|
||||||
res.json({ success: true, data: doc })
|
res.json({ success: true, data: doc })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err)
|
next(err)
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const createOrgSchema = z.object({
|
||||||
|
name: z.string().min(2, '企业名称至少2个字').max(50, '企业名称最多50个字'),
|
||||||
|
plan: z.enum(['FREE', 'PRO', 'ENTERPRISE']).default('FREE'),
|
||||||
|
maxEmployees: z.number().int().min(1).max(100000).default(20),
|
||||||
|
city: z.string().max(50).optional(),
|
||||||
|
contactName: z.string().max(30).optional(),
|
||||||
|
contactPhone: z.string().regex(/^1[3-9]\d{9}$/).optional(),
|
||||||
|
adminName: z.string().max(30).optional(),
|
||||||
|
adminPhone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||||
|
adminPassword: z.string().min(8, '密码至少8位').max(32, '密码最多32位'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateOrgSchema = z.object({
|
||||||
|
name: z.string().min(2).max(50).optional(),
|
||||||
|
plan: z.enum(['FREE', 'PRO', 'ENTERPRISE']).optional(),
|
||||||
|
maxEmployees: z.number().int().min(1).max(100000).optional(),
|
||||||
|
city: z.string().max(50).optional().nullable(),
|
||||||
|
contactName: z.string().max(30).optional().nullable(),
|
||||||
|
contactPhone: z.string().regex(/^1[3-9]\d{9}$/).optional().nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateOrgAdminSchema = z.object({
|
||||||
|
adminName: z.string().max(30).optional(),
|
||||||
|
adminPhone: z.string().regex(/^1[3-9]\d{9}$/).optional(),
|
||||||
|
adminPassword: z.string().min(8, '密码至少8位').max(32).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createPlatformAdminSchema = z.object({
|
||||||
|
name: z.string().min(1, '姓名不能为空').max(30),
|
||||||
|
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||||
|
password: z.string().min(8, '密码至少8位').max(32, '密码最多32位'),
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const createSpecialStatusSchema = z.object({
|
||||||
|
employeeId: z.string().min(1, '员工ID不能为空'),
|
||||||
|
type: z.enum([
|
||||||
|
'PREGNANCY', 'WORK_INJURY', 'MEDICAL_PERIOD', 'OTHER',
|
||||||
|
], { errorMap: () => ({ message: '无效的特殊状态类型' }) }),
|
||||||
|
status: z.enum(['ACTIVE', 'PENDING', 'RESOLVED']).default('ACTIVE'),
|
||||||
|
startDate: z.string().optional().nullable(),
|
||||||
|
endDate: z.string().optional().nullable(),
|
||||||
|
expectedDueDate: z.string().optional().nullable(),
|
||||||
|
injuryDate: z.string().optional().nullable(),
|
||||||
|
injuryDescription: z.string().max(500).optional().nullable(),
|
||||||
|
certificationDate: z.string().optional().nullable(),
|
||||||
|
certificationNo: z.string().max(50).optional().nullable(),
|
||||||
|
disabilityLevel: z.string().max(20).optional().nullable(),
|
||||||
|
assessmentDate: z.string().optional().nullable(),
|
||||||
|
medicalMonths: z.number().int().min(1).max(36).optional().nullable(),
|
||||||
|
description: z.string().max(500).optional().nullable(),
|
||||||
|
attachments: z.array(z.any()).optional().nullable(),
|
||||||
|
reminderDate: z.string().optional().nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateSpecialStatusSchema = z.object({
|
||||||
|
type: z.enum(['PREGNANCY', 'WORK_INJURY', 'MEDICAL_PERIOD', 'OTHER']).optional(),
|
||||||
|
status: z.enum(['ACTIVE', 'PENDING', 'RESOLVED']).optional(),
|
||||||
|
startDate: z.string().optional().nullable(),
|
||||||
|
endDate: z.string().optional().nullable(),
|
||||||
|
expectedDueDate: z.string().optional().nullable(),
|
||||||
|
injuryDate: z.string().optional().nullable(),
|
||||||
|
injuryDescription: z.string().max(500).optional().nullable(),
|
||||||
|
certificationDate: z.string().optional().nullable(),
|
||||||
|
certificationNo: z.string().max(50).optional().nullable(),
|
||||||
|
disabilityLevel: z.string().max(20).optional().nullable(),
|
||||||
|
assessmentDate: z.string().optional().nullable(),
|
||||||
|
medicalMonths: z.number().int().min(1).max(36).optional().nullable(),
|
||||||
|
description: z.string().max(500).optional().nullable(),
|
||||||
|
attachments: z.array(z.any()).optional().nullable(),
|
||||||
|
reminderDate: z.string().optional().nullable(),
|
||||||
|
})
|
||||||
@@ -13,3 +13,45 @@ export const terminationQuerySchema = z.object({
|
|||||||
page: z.coerce.number().min(1).default(1),
|
page: z.coerce.number().min(1).default(1),
|
||||||
pageSize: z.coerce.number().min(1).max(50).default(20),
|
pageSize: z.coerce.number().min(1).max(50).default(20),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const resignationSchema = z.object({
|
||||||
|
employeeId: z.string().min(1, '员工ID不能为空'),
|
||||||
|
terminationDate: z.string().min(1, '离职日期不能为空'),
|
||||||
|
resignationReason: z.string().max(200).optional(),
|
||||||
|
remark: z.string().max(500).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const batchTerminatePreviewSchema = z.object({
|
||||||
|
items: z.array(z.object({
|
||||||
|
employeeId: z.string().min(1),
|
||||||
|
reason: z.string().min(1, '解聘原因不能为空'),
|
||||||
|
terminationDate: z.string().min(1, '解聘日期不能为空'),
|
||||||
|
})).min(1, '至少选择一名员工'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const batchTerminateSchema = z.object({
|
||||||
|
items: z.array(z.object({
|
||||||
|
employeeId: z.string().min(1),
|
||||||
|
reason: z.string().min(1, '解聘原因不能为空'),
|
||||||
|
terminationDate: z.string().min(1, '解聘日期不能为空'),
|
||||||
|
compensation: z.number().min(0).optional(),
|
||||||
|
})).min(1, '至少选择一名员工'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createTerminationDraftSchema = z.object({
|
||||||
|
employeeId: z.string().min(1, '员工ID不能为空'),
|
||||||
|
type: z.enum(['TERMINATION', 'RESIGNATION']).optional(),
|
||||||
|
reason: z.string().max(200).optional(),
|
||||||
|
terminationDate: z.string().optional(),
|
||||||
|
compensation: z.number().min(0).optional(),
|
||||||
|
handoverItems: z.array(z.any()).optional(),
|
||||||
|
remark: z.string().max(500).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateTerminationDraftSchema = z.object({
|
||||||
|
reason: z.string().max(200).optional(),
|
||||||
|
terminationDate: z.string().optional(),
|
||||||
|
compensation: z.number().min(0).optional(),
|
||||||
|
handoverItems: z.array(z.any()).optional(),
|
||||||
|
remark: z.string().max(500).optional(),
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const createWorkProcessSchema = z.object({
|
||||||
|
type: z.enum([
|
||||||
|
'HIRE', 'ONBOARD', 'CUSTOM_CONTRACT', 'INFO_SUBMIT', 'CONFIRM',
|
||||||
|
'CHANGE', 'RENEW', 'SUSPEND', 'INCOME_CERT', 'TERMINATE',
|
||||||
|
'RESCIND', 'LEAVING_CERT', 'FLEXIBLE',
|
||||||
|
]),
|
||||||
|
title: z.string().max(100).optional(),
|
||||||
|
employeeId: z.string().optional().nullable(),
|
||||||
|
formData: z.record(z.any()).optional(),
|
||||||
|
status: z.enum(['DRAFT', 'COMPLETED']).default('DRAFT'),
|
||||||
|
remark: z.string().max(500).optional().nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateWorkProcessSchema = z.object({
|
||||||
|
title: z.string().max(100).optional(),
|
||||||
|
employeeId: z.string().optional().nullable(),
|
||||||
|
formData: z.record(z.any()).optional(),
|
||||||
|
remark: z.string().max(500).optional().nullable(),
|
||||||
|
})
|
||||||
@@ -205,6 +205,12 @@ export async function calcBatchEntry(
|
|||||||
housingOrg = Math.max(0, fullHousingOrg - deductedHousingOrg)
|
housingOrg = Math.max(0, fullHousingOrg - deductedHousingOrg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 保存系统计算值(覆盖前)
|
||||||
|
const systemSocialEmp = socialEmp
|
||||||
|
const systemSocialOrg = socialOrg
|
||||||
|
const systemHousingEmp = housingEmp
|
||||||
|
const systemHousingOrg = housingOrg
|
||||||
|
|
||||||
// 手动覆盖社保值
|
// 手动覆盖社保值
|
||||||
if (options?.overrideSocial) {
|
if (options?.overrideSocial) {
|
||||||
if (options.overrideSocial.socialEmp !== undefined) socialEmp = options.overrideSocial.socialEmp
|
if (options.overrideSocial.socialEmp !== undefined) socialEmp = options.overrideSocial.socialEmp
|
||||||
@@ -229,9 +235,11 @@ export async function calcBatchEntry(
|
|||||||
|
|
||||||
// 个税计算
|
// 个税计算
|
||||||
let tax = 0
|
let tax = 0
|
||||||
|
let taxBreakdown: any = null
|
||||||
if (batchType === 'BONUS') {
|
if (batchType === 'BONUS') {
|
||||||
// 年终奖单独计税
|
// 年终奖单独计税
|
||||||
tax = calcBonusTax(inputs.bonus)
|
tax = calcBonusTax(inputs.bonus)
|
||||||
|
taxBreakdown = { method: '单独计税(年终奖)', bonus: inputs.bonus, tax }
|
||||||
} else {
|
} else {
|
||||||
// 累计预扣法(补偿金也走累计预扣,但无社保公积金扣除)
|
// 累计预扣法(补偿金也走累计预扣,但无社保公积金扣除)
|
||||||
const year = month.slice(0, 4)
|
const year = month.slice(0, 4)
|
||||||
@@ -252,8 +260,22 @@ export async function calcBatchEntry(
|
|||||||
const ytdHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp, 0) + housingEmp
|
const ytdHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp, 0) + housingEmp
|
||||||
const ytdSpecialDeduction = employee.specialDeduction * Number(month.slice(5, 7))
|
const ytdSpecialDeduction = employee.specialDeduction * Number(month.slice(5, 7))
|
||||||
const ytdTaxDeducted = archivedEntries.reduce((s, e) => s + e.tax, 0)
|
const ytdTaxDeducted = archivedEntries.reduce((s, e) => s + e.tax, 0)
|
||||||
const ytdTaxableIncome = Math.max(0, ytdIncome - 5000 * Number(month.slice(5, 7)) - ytdSocialEmp - ytdHousingEmp - ytdSpecialDeduction)
|
const deductionAmount = 5000 * Number(month.slice(5, 7))
|
||||||
|
const ytdTaxableIncome = Math.max(0, ytdIncome - deductionAmount - ytdSocialEmp - ytdHousingEmp - ytdSpecialDeduction)
|
||||||
tax = calcCumulativeTax(ytdTaxableIncome, ytdTaxDeducted)
|
tax = calcCumulativeTax(ytdTaxableIncome, ytdTaxDeducted)
|
||||||
|
taxBreakdown = {
|
||||||
|
method: '累计预扣法',
|
||||||
|
month: Number(month.slice(5, 7)),
|
||||||
|
ytdIncome,
|
||||||
|
deductionAmount,
|
||||||
|
ytdSocialEmp,
|
||||||
|
ytdHousingEmp,
|
||||||
|
ytdSpecialDeduction,
|
||||||
|
ytdTaxableIncome,
|
||||||
|
ytdTaxDeducted,
|
||||||
|
currentMonthTax: tax,
|
||||||
|
archivedCount: archivedEntries.length,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const netPay = totalPay - socialEmp - housingEmp - tax
|
const netPay = totalPay - socialEmp - housingEmp - tax
|
||||||
@@ -263,7 +285,12 @@ export async function calcBatchEntry(
|
|||||||
socialOrg: Math.round(socialOrg * 100) / 100,
|
socialOrg: Math.round(socialOrg * 100) / 100,
|
||||||
housingEmp: Math.round(housingEmp * 100) / 100,
|
housingEmp: Math.round(housingEmp * 100) / 100,
|
||||||
housingOrg: Math.round(housingOrg * 100) / 100,
|
housingOrg: Math.round(housingOrg * 100) / 100,
|
||||||
|
systemSocialEmp: Math.round(systemSocialEmp * 100) / 100,
|
||||||
|
systemSocialOrg: Math.round(systemSocialOrg * 100) / 100,
|
||||||
|
systemHousingEmp: Math.round(systemHousingEmp * 100) / 100,
|
||||||
|
systemHousingOrg: Math.round(systemHousingOrg * 100) / 100,
|
||||||
tax,
|
tax,
|
||||||
|
taxBreakdown,
|
||||||
totalPay: Math.round(totalPay * 100) / 100,
|
totalPay: Math.round(totalPay * 100) / 100,
|
||||||
netPay: Math.round(netPay * 100) / 100,
|
netPay: Math.round(netPay * 100) / 100,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -666,7 +666,7 @@ export async function getDashboardData(orgId: string) {
|
|||||||
const yearEnd = new Date(now.getFullYear(), 11, 31, 23, 59, 59)
|
const yearEnd = new Date(now.getFullYear(), 11, 31, 23, 59, 59)
|
||||||
|
|
||||||
const [
|
const [
|
||||||
employeeCount, highRisks, pendingRisks, riskItems, resolvedItems,
|
employeeCount, _highRisks, _pendingRisks, riskItems, resolvedItems,
|
||||||
overtimeRecords, payslips, batchEntries, socialConfig, housingConfig,
|
overtimeRecords, payslips, batchEntries, socialConfig, housingConfig,
|
||||||
monthContracts, monthTerminations, monthDisciplinary, monthAttendance,
|
monthContracts, monthTerminations, monthDisciplinary, monthAttendance,
|
||||||
monthSeverancePay,
|
monthSeverancePay,
|
||||||
@@ -1217,17 +1217,13 @@ export async function getCostAnalysis(orgId: string, month: string) {
|
|||||||
const monthNum = parseInt(month.slice(5, 7))
|
const monthNum = parseInt(month.slice(5, 7))
|
||||||
|
|
||||||
// 当月数据
|
// 当月数据
|
||||||
const currentMonthStart = new Date(year, monthNum - 1, 1)
|
|
||||||
const currentMonthEnd = new Date(year, monthNum, 0, 23, 59, 59)
|
|
||||||
|
|
||||||
// 上月(环比)
|
// 上月(环比)
|
||||||
const prevMonthStart = new Date(year, monthNum - 2, 1)
|
const prevMonthStart = new Date(year, monthNum - 2, 1)
|
||||||
const prevMonthEnd = new Date(year, monthNum - 1, 0, 23, 59, 59)
|
|
||||||
const prevMonthStr = `${prevMonthStart.getFullYear()}-${String(prevMonthStart.getMonth() + 1).padStart(2, '0')}`
|
const prevMonthStr = `${prevMonthStart.getFullYear()}-${String(prevMonthStart.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
|
||||||
// 去年同月(同比)
|
// 去年同月(同比)
|
||||||
const lastYearMonthStart = new Date(year - 1, monthNum - 1, 1)
|
const lastYearMonthStart = new Date(year - 1, monthNum - 1, 1)
|
||||||
const lastYearMonthEnd = new Date(year - 1, monthNum, 0, 23, 59, 59)
|
|
||||||
const lastYearMonthStr = `${lastYearMonthStart.getFullYear()}-${String(lastYearMonthStart.getMonth() + 1).padStart(2, '0')}`
|
const lastYearMonthStr = `${lastYearMonthStart.getFullYear()}-${String(lastYearMonthStart.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
|
||||||
// 获取各月归档批次汇总(按员工去重,与 getDashboardData 口径一致)
|
// 获取各月归档批次汇总(按员工去重,与 getDashboardData 口径一致)
|
||||||
@@ -1802,7 +1798,7 @@ export async function getAnnualValueReport(orgId: string, year: number) {
|
|||||||
|
|
||||||
const [
|
const [
|
||||||
risksResolved,
|
risksResolved,
|
||||||
lossAvoidedAgg,
|
_lossAvoidedAgg,
|
||||||
aiConversations,
|
aiConversations,
|
||||||
aiReviews,
|
aiReviews,
|
||||||
contractsSigned,
|
contractsSigned,
|
||||||
@@ -2043,11 +2039,6 @@ export async function getAnnualValueReport(orgId: string, year: number) {
|
|||||||
// 总价值 = 规避损失 + 节约成本
|
// 总价值 = 规避损失 + 节约成本
|
||||||
const totalValue = adjustedLossAvoided + costSaved
|
const totalValue = adjustedLossAvoided + costSaved
|
||||||
|
|
||||||
// ROI = 总价值 / 系统成本(年费 12000 元),上限 9999%
|
|
||||||
const systemCost = 12000
|
|
||||||
const rawRoi = systemCost > 0 ? Math.round((totalValue / systemCost) * 100) : 0
|
|
||||||
const roi = Math.min(rawRoi, 9999)
|
|
||||||
|
|
||||||
const metrics = {
|
const metrics = {
|
||||||
risksResolved,
|
risksResolved,
|
||||||
lossAvoided,
|
lossAvoided,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import prisma from '../lib/prisma'
|
import prisma from '../lib/prisma'
|
||||||
import { encrypt } from '../lib/crypto'
|
import { encrypt } from '../lib/crypto'
|
||||||
import crypto from 'crypto'
|
import { createDraft as createTerminationDraft, executeTermination } from './termination.service'
|
||||||
import { createDraft as createTerminationDraft, executeTermination, createResignation } from './termination.service'
|
import { createEmployee, addContract } from './contract.service'
|
||||||
import { createEmployee, addContract, batchRenew } from './contract.service'
|
|
||||||
import { runRiskDetection } from './risk.service'
|
import { runRiskDetection } from './risk.service'
|
||||||
|
|
||||||
// 13类流程定义
|
// 13类流程定义
|
||||||
@@ -67,7 +66,7 @@ export async function executeWorkProcess(processId: string, type: string, formDa
|
|||||||
return { employeeId }
|
return { employeeId }
|
||||||
}
|
}
|
||||||
case 'CONFIRM': {
|
case 'CONFIRM': {
|
||||||
const { employeeId, confirmDate, regularSalary } = formData
|
const { employeeId, regularSalary } = formData
|
||||||
if (employeeId) {
|
if (employeeId) {
|
||||||
if (regularSalary) {
|
if (regularSalary) {
|
||||||
await prisma.employee.update({
|
await prisma.employee.update({
|
||||||
@@ -196,7 +195,7 @@ export async function executeWorkProcess(processId: string, type: string, formDa
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
case 'FLEXIBLE': {
|
case 'FLEXIBLE': {
|
||||||
const { name, phone, idCardNumber, department, agreementStartDate, agreementEndDate, payMethod } = formData
|
const { name, phone, idCardNumber, department, agreementStartDate, agreementEndDate } = formData
|
||||||
const empResult = await createEmployee(orgId, userId, {
|
const empResult = await createEmployee(orgId, userId, {
|
||||||
name,
|
name,
|
||||||
department: department || '灵活用工',
|
department: department || '灵活用工',
|
||||||
@@ -244,7 +243,23 @@ export async function executeWorkProcess(processId: string, type: string, formDa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 生成文书预览
|
// 生成文书预览
|
||||||
export function generateDocument(type: string, formData: any, orgName: string): { name: string; content: string } {
|
export async function generateDocument(type: string, formData: any, orgName: string): Promise<{ name: string; content: string }> {
|
||||||
|
// 如果指定了企业自定义模板,使用企业模板渲染
|
||||||
|
if (formData.enterpriseTemplateId) {
|
||||||
|
const tpl = await (prisma as any).enterpriseTemplate.findFirst({
|
||||||
|
where: { id: formData.enterpriseTemplateId },
|
||||||
|
})
|
||||||
|
if (tpl) {
|
||||||
|
let content = tpl.content
|
||||||
|
// 替换变量 {{var}}
|
||||||
|
const allVars: Record<string, string> = { ...formData, companyName: orgName }
|
||||||
|
for (const [key, value] of Object.entries(allVars)) {
|
||||||
|
content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), String(value ?? ''))
|
||||||
|
}
|
||||||
|
return { name: `${tpl.name}.doc`, content }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const templates: Record<string, (data: any, org: string) => string> = {
|
const templates: Record<string, (data: any, org: string) => string> = {
|
||||||
INCOME_CERT: (data, org) => `收入证明
|
INCOME_CERT: (data, org) => `收入证明
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||||
|
<meta http-equiv="Pragma" content="no-cache">
|
||||||
|
<meta http-equiv="Expires" content="0">
|
||||||
<title>TurboHR 验收测试清单</title>
|
<title>TurboHR 验收测试清单</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
@@ -51,6 +54,7 @@ body {
|
|||||||
}
|
}
|
||||||
.header .btn:hover { opacity: 0.85; }
|
.header .btn:hover { opacity: 0.85; }
|
||||||
.header .btn-save { background: var(--primary); color: #fff; }
|
.header .btn-save { background: var(--primary); color: #fff; }
|
||||||
|
.header .btn-submit { background: var(--success); color: #fff; }
|
||||||
.header .btn-load { background: var(--primary-light); color: var(--primary); }
|
.header .btn-load { background: var(--primary-light); color: var(--primary); }
|
||||||
.header .btn-delete { background: #fee2e2; color: var(--danger); }
|
.header .btn-delete { background: #fee2e2; color: var(--danger); }
|
||||||
.header .btn-download { background: #d1fae5; color: var(--success); }
|
.header .btn-download { background: #d1fae5; color: var(--success); }
|
||||||
@@ -177,6 +181,68 @@ body {
|
|||||||
/* Collapsed state */
|
/* Collapsed state */
|
||||||
.domain-section.collapsed .test-table { display: none; }
|
.domain-section.collapsed .test-table { display: none; }
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
.toolbar {
|
||||||
|
background: #fff; border-bottom: 1px solid var(--border);
|
||||||
|
padding: 8px 24px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.toolbar .tool-btn {
|
||||||
|
padding: 4px 12px; border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
font-size: 0.75rem; cursor: pointer; background: #fff; color: #4b5563;
|
||||||
|
transition: all 0.15s; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.toolbar .tool-btn:hover { background: var(--gray-light); }
|
||||||
|
.toolbar .tool-btn.active { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||||
|
.toolbar .tool-divider { width: 1px; height: 20px; background: var(--border); margin: 0 4px; }
|
||||||
|
.toolbar .tool-search {
|
||||||
|
padding: 4px 10px; border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
font-size: 0.75rem; width: 160px;
|
||||||
|
}
|
||||||
|
.toolbar .tool-info { font-size: 0.6875rem; color: var(--gray); margin-left: auto; }
|
||||||
|
|
||||||
|
/* Overall progress bar */
|
||||||
|
.overall-progress {
|
||||||
|
height: 4px; background: #e5e7eb; border-radius: 2px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.overall-progress-fill {
|
||||||
|
height: 100%; background: linear-gradient(90deg, var(--primary), var(--success));
|
||||||
|
transition: width 0.4s; width: 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Row highlight for filtered */
|
||||||
|
.test-table tr.filtered-out { display: none; }
|
||||||
|
.test-table tr.row-fail { background: #fef2f2; }
|
||||||
|
.test-table tr.row-partial { background: #fffbeb; }
|
||||||
|
|
||||||
|
/* Screenshot upload */
|
||||||
|
.screenshot-cell { min-width: 60px; }
|
||||||
|
.screenshot-thumb { width: 40px; height: 40px; object-fit: cover; border-radius: 4px; cursor: pointer; border: 1px solid var(--border); }
|
||||||
|
.screenshot-upload {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 28px; height: 28px; border: 1px dashed var(--border); border-radius: 4px;
|
||||||
|
cursor: pointer; color: var(--gray); font-size: 1rem;
|
||||||
|
}
|
||||||
|
.screenshot-upload:hover { border-color: var(--primary); color: var(--primary); }
|
||||||
|
|
||||||
|
/* Signature */
|
||||||
|
.signature-section {
|
||||||
|
background: #fff; border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); margin-top: 16px; padding: 20px;
|
||||||
|
}
|
||||||
|
.signature-section h2 { font-size: 0.9375rem; font-weight: 600; margin-bottom: 12px; }
|
||||||
|
.signature-box {
|
||||||
|
border: 1px dashed var(--border); border-radius: 8px;
|
||||||
|
min-height: 80px; display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--gray); font-size: 0.8125rem; cursor: crosshair;
|
||||||
|
position: relative; background: #fafafa;
|
||||||
|
}
|
||||||
|
.signature-box canvas { width: 100%; height: 80px; }
|
||||||
|
.signature-box .sig-clear {
|
||||||
|
position: absolute; top: 4px; right: 8px; font-size: 0.6875rem;
|
||||||
|
color: var(--danger); cursor: pointer; background: #fff; padding: 2px 6px;
|
||||||
|
border-radius: 4px; border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
/* Autocomplete dropdown */
|
/* Autocomplete dropdown */
|
||||||
.autocomplete-wrap { position: relative; display: inline-block; }
|
.autocomplete-wrap { position: relative; display: inline-block; }
|
||||||
.autocomplete-list {
|
.autocomplete-list {
|
||||||
@@ -198,11 +264,15 @@ body {
|
|||||||
.autocomplete-empty { padding: 8px 12px; font-size: 0.75rem; color: var(--gray); text-align: center; }
|
.autocomplete-empty { padding: 8px 12px; font-size: 0.75rem; color: var(--gray); text-align: center; }
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
.header, .stats-bar { position: static; }
|
.header, .stats-bar, .toolbar { display: none !important; }
|
||||||
.domain-section { break-inside: avoid; }
|
.domain-section { break-inside: avoid; }
|
||||||
.domain-section.collapsed .test-table { display: table !important; }
|
.domain-section.collapsed .test-table { display: table !important; }
|
||||||
.result-radio label { border: 1px solid #ccc; }
|
.result-radio label { border: 1px solid #ccc; }
|
||||||
.btn { display: none !important; }
|
.btn, .tool-btn, .sig-clear, .screenshot-upload { display: none !important; }
|
||||||
|
.overall-progress { display: none !important; }
|
||||||
|
.signature-box { border: 1px solid #333; }
|
||||||
|
.main { padding: 0; }
|
||||||
|
body { background: #fff; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -221,11 +291,11 @@ body {
|
|||||||
<option value="">-- 加载已保存 --</option>
|
<option value="">-- 加载已保存 --</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="btn btn-save" onclick="saveCurrent()">保存</button>
|
<button class="btn btn-save" onclick="saveCurrent()">保存</button>
|
||||||
|
<button class="btn btn-submit" onclick="submitReport()">提交报告</button>
|
||||||
<button class="btn btn-delete" onclick="deleteSaved()">删除当前</button>
|
<button class="btn btn-delete" onclick="deleteSaved()">删除当前</button>
|
||||||
<button class="btn btn-delete" onclick="clearAllSaved()" style="background:#fee2e2;color:#ef4444">清空全部</button>
|
|
||||||
<button class="btn btn-download" onclick="downloadCSV()">下载CSV</button>
|
<button class="btn btn-download" onclick="downloadCSV()">下载CSV</button>
|
||||||
<button class="btn btn-download" onclick="downloadExcel()">下载Excel</button>
|
<button class="btn btn-download" onclick="downloadExcel()">下载Excel</button>
|
||||||
<button class="btn btn-print" onclick="window.print()">打印</button>
|
<button class="btn btn-print" onclick="exportPDF()">导出PDF</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -239,6 +309,25 @@ body {
|
|||||||
<div class="stat-item">完成率 <span class="stat-num" id="statRate">0%</span></div>
|
<div class="stat-item">完成率 <span class="stat-num" id="statRate">0%</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Overall progress bar -->
|
||||||
|
<div class="overall-progress"><div class="overall-progress-fill" id="overallProgress"></div></div>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="tool-btn" onclick="expandAll()">展开全部</button>
|
||||||
|
<button class="tool-btn" onclick="collapseAll()">折叠全部</button>
|
||||||
|
<div class="tool-divider"></div>
|
||||||
|
<button class="tool-btn" onclick="setFilter('all')" id="filterAll">全部</button>
|
||||||
|
<button class="tool-btn" onclick="setFilter('untested')" id="filterUntested">未测试</button>
|
||||||
|
<button class="tool-btn" onclick="setFilter('fail')" id="filterFail">失败</button>
|
||||||
|
<button class="tool-btn" onclick="setFilter('partial')" id="filterPartial">部分通过</button>
|
||||||
|
<div class="tool-divider"></div>
|
||||||
|
<input type="text" class="tool-search" id="searchInput" placeholder="搜索功能/步骤..." oninput="onSearch()">
|
||||||
|
<div class="tool-divider"></div>
|
||||||
|
<button class="tool-btn" onclick="markAllPass()" title="将所有未测试项标记为通过">全部标通过</button>
|
||||||
|
<span class="tool-info">快捷键: P=通过 F=失败 W=部分通过 U=未测试 ↑↓=切换行</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<div class="main" id="mainContent"></div>
|
<div class="main" id="mainContent"></div>
|
||||||
|
|
||||||
@@ -516,10 +605,68 @@ const DOMAINS = [
|
|||||||
{ id:"24.2", func:"命令面板", steps:"按Cmd+K/Ctrl+K", expected:"弹出命令面板,可快速跳转" },
|
{ id:"24.2", func:"命令面板", steps:"按Cmd+K/Ctrl+K", expected:"弹出命令面板,可快速跳转" },
|
||||||
{ id:"24.3", func:"搜索无结果", steps:"输入不存在的关键词", expected:"显示未找到相关结果" },
|
{ id:"24.3", func:"搜索无结果", steps:"输入不存在的关键词", expected:"显示未找到相关结果" },
|
||||||
]},
|
]},
|
||||||
|
{ name: "25. 合同管理", items: [
|
||||||
|
{ id:"25.1", func:"合同列表", steps:"访问合同管理页面", expected:"显示合同列表,含员工/类型/起止日/状态" },
|
||||||
|
{ id:"25.2", func:"合同分页", steps:"翻页查看合同列表", expected:"分页正常,每页显示对应数量" },
|
||||||
|
{ id:"25.3", func:"合同搜索", steps:"搜索员工姓名", expected:"列表过滤显示匹配合同" },
|
||||||
|
{ id:"25.4", func:"合同筛选(状态)", steps:"筛选即将到期/已过期", expected:"仅显示对应状态合同" },
|
||||||
|
{ id:"25.5", func:"合同详情", steps:"点击合同查看详情", expected:"显示合同完整信息" },
|
||||||
|
{ id:"25.6", func:"添加合同", steps:"点击添加→填写信息→保存", expected:"新合同创建成功" },
|
||||||
|
{ id:"25.7", func:"编辑合同", steps:"编辑已有合同→保存", expected:"合同信息更新" },
|
||||||
|
{ id:"25.8", func:"删除合同", steps:"删除某合同", expected:"合同从列表消失" },
|
||||||
|
{ id:"25.9", func:"合同到期预警", steps:"查看即将到期合同", expected:"显示到期天数提醒" },
|
||||||
|
]},
|
||||||
|
{ name: "26. 薪酬模版管理", items: [
|
||||||
|
{ id:"26.1", func:"薪酬模版列表", steps:"访问薪酬模版页面", expected:"显示模版项列表" },
|
||||||
|
{ id:"26.2", func:"创建薪酬模版", steps:"填写名称/类型/公式→保存", expected:"新模版项创建成功" },
|
||||||
|
{ id:"26.3", func:"编辑薪酬模版", steps:"编辑已有模版→保存", expected:"模版信息更新" },
|
||||||
|
{ id:"26.4", func:"删除薪酬模版", steps:"删除某模版项", expected:"模版项从列表消失" },
|
||||||
|
{ id:"26.5", func:"模版分类筛选", steps:"按类型筛选(收入/扣除)", expected:"仅显示对应类型模版" },
|
||||||
|
]},
|
||||||
|
{ name: "27. 年度价值报告", items: [
|
||||||
|
{ id:"27.1", func:"年度价值报告", steps:"查看首页年度价值报告", expected:"显示年度价值报告卡片" },
|
||||||
|
{ id:"27.2", func:"价值报告详情", steps:"点击查看详情", expected:"显示年度人力成本/效率/风险等指标" },
|
||||||
|
]},
|
||||||
|
{ name: "28. 验收测试", items: [
|
||||||
|
{ id:"28.1", func:"验收测试保存", steps:"填写验收人→标记测试项→保存", expected:"保存成功,toast提示" },
|
||||||
|
{ id:"28.2", func:"验收测试加载", steps:"重新进入→选择已保存验收人", expected:"恢复之前保存的测试结果" },
|
||||||
|
{ id:"28.3", func:"验收测试提交", steps:"完成测试→点击提交报告", expected:"状态变为已提交" },
|
||||||
|
{ id:"28.4", func:"验收结论自动计算", steps:"标记测试项结果", expected:"总体结论根据结果自动计算" },
|
||||||
|
{ id:"28.5", func:"验收测试筛选", steps:"点击未测试/失败筛选", expected:"仅显示对应结果测试项" },
|
||||||
|
{ id:"28.6", func:"验收测试搜索", steps:"输入关键词搜索", expected:"仅显示匹配的测试项" },
|
||||||
|
{ id:"28.7", func:"验收测试导出", steps:"下载CSV/Excel/PDF", expected:"文件下载成功,内容完整" },
|
||||||
|
{ id:"28.8", func:"验收测试截图", steps:"点击截图列→上传图片", expected:"截图显示在单元格中" },
|
||||||
|
{ id:"28.9", func:"验收测试签字", steps:"在签名区域手写签名", expected:"签名显示在画布上" },
|
||||||
|
]},
|
||||||
];
|
];
|
||||||
|
|
||||||
const RESULT_LABELS = { pass: "✅", fail: "❌", partial: "⚠️", untested: "🚫" };
|
const RESULT_LABELS = { pass: "✅", fail: "❌", partial: "⚠️", untested: "🚫" };
|
||||||
const STORAGE_PREFIX = "turbohr_acceptance_";
|
|
||||||
|
// ========== API 工具 ==========
|
||||||
|
function getAuthToken() {
|
||||||
|
try {
|
||||||
|
const raw = window.parent.localStorage.getItem('auth-storage');
|
||||||
|
if (raw) return JSON.parse(raw).state?.accessToken || '';
|
||||||
|
} catch {}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE = (() => {
|
||||||
|
try {
|
||||||
|
const loc = window.parent.location;
|
||||||
|
if (loc.port === '5173' || loc.port === '3001') return 'http://localhost:3000/api/v1';
|
||||||
|
return loc.origin + '/api/v1';
|
||||||
|
} catch { return '/api/v1'; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
async function apiCall(method, path, body) {
|
||||||
|
const token = getAuthToken();
|
||||||
|
const opts = { method, headers: { 'Content-Type': 'application/json', 'Authorization': token ? `Bearer ${token}` : '' } };
|
||||||
|
if (body) opts.body = JSON.stringify(body);
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, opts);
|
||||||
|
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error?.message || `请求失败(${res.status})`); }
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 渲染 ==========
|
// ========== 渲染 ==========
|
||||||
function renderAll() {
|
function renderAll() {
|
||||||
@@ -538,11 +685,12 @@ function renderAll() {
|
|||||||
<th class="col-num">#</th><th class="col-func">功能</th>
|
<th class="col-num">#</th><th class="col-func">功能</th>
|
||||||
<th class="col-steps">操作步骤</th><th class="col-expected">预期结果</th>
|
<th class="col-steps">操作步骤</th><th class="col-expected">预期结果</th>
|
||||||
<th class="col-result">结果</th><th class="col-remark">备注</th>
|
<th class="col-result">结果</th><th class="col-remark">备注</th>
|
||||||
|
<th class="screenshot-cell">截图</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>`;
|
<tbody>`;
|
||||||
domain.items.forEach((item, ii) => {
|
domain.items.forEach((item, ii) => {
|
||||||
const uid = `${di}-${ii}`;
|
const uid = `${di}-${ii}`;
|
||||||
html += `<tr>
|
html += `<tr id="row-${uid}">
|
||||||
<td class="col-num">${item.id}</td>
|
<td class="col-num">${item.id}</td>
|
||||||
<td class="col-func">${item.func}</td>
|
<td class="col-func">${item.func}</td>
|
||||||
<td class="col-steps">${item.steps}</td>
|
<td class="col-steps">${item.steps}</td>
|
||||||
@@ -556,6 +704,7 @@ function renderAll() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-remark"><input type="text" class="remark-input" id="remark-${uid}" placeholder="备注" onchange="onRemarkChange(${di},${ii},this.value)"></td>
|
<td class="col-remark"><input type="text" class="remark-input" id="remark-${uid}" placeholder="备注" onchange="onRemarkChange(${di},${ii},this.value)"></td>
|
||||||
|
<td class="screenshot-cell"><div class="screenshot-upload" onclick="uploadScreenshot(${di},${ii})" title="上传截图">+</div></td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
});
|
});
|
||||||
html += `</tbody></table></div>`;
|
html += `</tbody></table></div>`;
|
||||||
@@ -584,24 +733,41 @@ function renderAll() {
|
|||||||
</tr>`;
|
</tr>`;
|
||||||
html += `</tbody></table></div>`;
|
html += `</tbody></table></div>`;
|
||||||
|
|
||||||
// Conclusion
|
// Conclusion (auto-calculated)
|
||||||
html += `<div class="conclusion-section">
|
html += `<div class="conclusion-section">
|
||||||
<h2>验收结论</h2>
|
<h2>验收结论(系统自动计算)</h2>
|
||||||
<div class="field"><label>验收人</label><input type="text" id="conclusionName" placeholder="验收人姓名"></div>
|
<div class="field"><label>验收人</label><input type="text" id="conclusionName" placeholder="验收人姓名" readonly></div>
|
||||||
<div class="field"><label>验收日期</label><input type="text" id="conclusionDate" placeholder="YYYY-MM-DD"></div>
|
<div class="field"><label>验收日期</label><input type="text" id="conclusionDate" placeholder="YYYY-MM-DD" readonly></div>
|
||||||
<div class="field"><label>总体结论</label>
|
<div class="field"><label>总体结论</label><span id="conclusionResult" style="font-size:0.875rem;font-weight:600;color:var(--gray);">未测试</span></div>
|
||||||
<div class="checkbox-group">
|
<div class="field"><label>通过率</label><span id="conclusionPassRate" style="font-size:0.875rem;">-</span></div>
|
||||||
<label><input type="radio" name="conclusion" value="pass"> 通过</label>
|
<div class="field"><label>主要问题</label></div>
|
||||||
<label><input type="radio" name="conclusion" value="conditional"> 有条件通过</label>
|
<textarea id="conclusionIssues" placeholder="系统自动汇总失败和部分通过的问题..." readonly style="background:#f9fafb;"></textarea>
|
||||||
<label><input type="radio" name="conclusion" value="fail"> 不通过</label>
|
</div>`;
|
||||||
|
|
||||||
|
// Signature
|
||||||
|
html += `<div class="signature-section">
|
||||||
|
<h2>验收签字</h2>
|
||||||
|
<div style="display:flex;gap:40px;flex-wrap:wrap">
|
||||||
|
<div style="flex:1;min-width:300px">
|
||||||
|
<div style="font-size:0.8125rem;color:var(--gray);margin-bottom:6px">验收人签字</div>
|
||||||
|
<div class="signature-box" id="sigBox1">
|
||||||
|
<canvas id="sigCanvas1"></canvas>
|
||||||
|
<span class="sig-clear" onclick="clearSig(1)">清除</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-width:300px">
|
||||||
|
<div style="font-size:0.8125rem;color:var(--gray);margin-bottom:6px">项目经理签字</div>
|
||||||
|
<div class="signature-box" id="sigBox2">
|
||||||
|
<canvas id="sigCanvas2"></canvas>
|
||||||
|
<span class="sig-clear" onclick="clearSig(2)">清除</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>遗留问题</label></div>
|
|
||||||
<textarea id="conclusionIssues" placeholder="1. ... 2. ... 3. ..."></textarea>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
main.innerHTML = html;
|
main.innerHTML = html;
|
||||||
updateStats();
|
updateStats();
|
||||||
|
initSignatures();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 交互 ==========
|
// ========== 交互 ==========
|
||||||
@@ -612,58 +778,215 @@ function toggleDomain(di) {
|
|||||||
toggle.textContent = el.classList.contains("collapsed") ? "▶" : "▼";
|
toggle.textContent = el.classList.contains("collapsed") ? "▶" : "▼";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expandAll() {
|
||||||
|
DOMAINS.forEach((_, di) => {
|
||||||
|
const el = document.getElementById(`domain-${di}`);
|
||||||
|
if (el) { el.classList.remove("collapsed"); el.querySelector(".toggle").textContent = "▼"; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseAll() {
|
||||||
|
DOMAINS.forEach((_, di) => {
|
||||||
|
const el = document.getElementById(`domain-${di}`);
|
||||||
|
if (el) { el.classList.add("collapsed"); el.querySelector(".toggle").textContent = "▶"; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 筛选 ==========
|
||||||
|
let currentFilter = 'all';
|
||||||
|
|
||||||
|
function setFilter(filter) {
|
||||||
|
currentFilter = filter;
|
||||||
|
['all','untested','fail','partial'].forEach(f => {
|
||||||
|
const btn = document.getElementById('filter' + f.charAt(0).toUpperCase() + f.slice(1));
|
||||||
|
if (btn) btn.classList.toggle('active', f === filter);
|
||||||
|
});
|
||||||
|
applyFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilter() {
|
||||||
|
const searchQ = (document.getElementById('searchInput')?.value || '').toLowerCase();
|
||||||
|
DOMAINS.forEach((domain, di) => {
|
||||||
|
domain.items.forEach((item, ii) => {
|
||||||
|
const uid = `${di}-${ii}`;
|
||||||
|
const row = document.getElementById(`row-${uid}`);
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
// Get current result
|
||||||
|
const checked = document.querySelector(`input[name="r-${uid}"]:checked`);
|
||||||
|
const val = checked ? checked.value : "untested";
|
||||||
|
|
||||||
|
// Filter by result
|
||||||
|
let showByFilter = true;
|
||||||
|
if (currentFilter === 'untested' && val !== 'untested') showByFilter = false;
|
||||||
|
else if (currentFilter === 'fail' && val !== 'fail') showByFilter = false;
|
||||||
|
else if (currentFilter === 'partial' && val !== 'partial') showByFilter = false;
|
||||||
|
|
||||||
|
// Filter by search
|
||||||
|
let showBySearch = true;
|
||||||
|
if (searchQ) {
|
||||||
|
const text = (item.func + item.steps + item.expected + item.id).toLowerCase();
|
||||||
|
showBySearch = text.includes(searchQ);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.classList.toggle('filtered-out', !(showByFilter && showBySearch));
|
||||||
|
|
||||||
|
// Row color
|
||||||
|
row.classList.remove('row-fail', 'row-partial');
|
||||||
|
if (val === 'fail') row.classList.add('row-fail');
|
||||||
|
else if (val === 'partial') row.classList.add('row-partial');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-expand domain if it has visible items when filtering
|
||||||
|
if (currentFilter !== 'all' || searchQ) {
|
||||||
|
const el = document.getElementById(`domain-${di}`);
|
||||||
|
const hasVisible = domain.items.some((_, ii) => {
|
||||||
|
const row = document.getElementById(`row-${di}-${ii}`);
|
||||||
|
return row && !row.classList.contains('filtered-out');
|
||||||
|
});
|
||||||
|
if (hasVisible && el) { el.classList.remove("collapsed"); el.querySelector(".toggle").textContent = "▼"; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch() { applyFilter(); }
|
||||||
|
|
||||||
|
function markAllPass() {
|
||||||
|
if (!confirm('将所有「未测试」项标记为「通过」?')) return;
|
||||||
|
DOMAINS.forEach((domain, di) => {
|
||||||
|
domain.items.forEach((item, ii) => {
|
||||||
|
const uid = `${di}-${ii}`;
|
||||||
|
const checked = document.querySelector(`input[name="r-${uid}"]:checked`);
|
||||||
|
const val = checked ? checked.value : "untested";
|
||||||
|
if (val === "untested") {
|
||||||
|
const radio = document.querySelector(`input[name="r-${uid}"][value="pass"]`);
|
||||||
|
if (radio) radio.checked = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
updateStats();
|
||||||
|
scheduleAutoSave();
|
||||||
|
showToast("已将所有未测试项标记为通过");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 键盘快捷键 ==========
|
||||||
|
let currentRowIdx = 0;
|
||||||
|
const flatItems = [];
|
||||||
|
function buildFlatItems() {
|
||||||
|
flatItems.length = 0;
|
||||||
|
DOMAINS.forEach((domain, di) => {
|
||||||
|
domain.items.forEach((item, ii) => {
|
||||||
|
flatItems.push({ di, ii, uid: `${di}-${ii}` });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
// Skip if typing in input
|
||||||
|
if (e.target.tagName === 'INPUT' && e.target.type === 'text') return;
|
||||||
|
if (e.target.tagName === 'TEXTAREA') return;
|
||||||
|
if (e.target.tagName === 'SELECT') return;
|
||||||
|
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
const resultMap = { p: 'pass', f: 'fail', w: 'partial', u: 'untested' };
|
||||||
|
|
||||||
|
if (key === 'arrowdown') {
|
||||||
|
e.preventDefault();
|
||||||
|
buildFlatItems();
|
||||||
|
currentRowIdx = Math.min(currentRowIdx + 1, flatItems.length - 1);
|
||||||
|
focusRow(currentRowIdx);
|
||||||
|
} else if (key === 'arrowup') {
|
||||||
|
e.preventDefault();
|
||||||
|
buildFlatItems();
|
||||||
|
currentRowIdx = Math.max(currentRowIdx - 1, 0);
|
||||||
|
focusRow(currentRowIdx);
|
||||||
|
} else if (resultMap[key]) {
|
||||||
|
e.preventDefault();
|
||||||
|
buildFlatItems();
|
||||||
|
if (currentRowIdx >= 0 && currentRowIdx < flatItems.length) {
|
||||||
|
const { di, ii, uid } = flatItems[currentRowIdx];
|
||||||
|
const radio = document.querySelector(`input[name="r-${uid}"][value="${resultMap[key]}"]`);
|
||||||
|
if (radio) {
|
||||||
|
radio.checked = true;
|
||||||
|
onResultChange(di, ii, resultMap[key]);
|
||||||
|
// Auto-advance to next row
|
||||||
|
if (key !== 'u') {
|
||||||
|
currentRowIdx = Math.min(currentRowIdx + 1, flatItems.length - 1);
|
||||||
|
setTimeout(() => focusRow(currentRowIdx), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function focusRow(idx) {
|
||||||
|
buildFlatItems();
|
||||||
|
if (idx >= 0 && idx < flatItems.length) {
|
||||||
|
const { uid } = flatItems[idx];
|
||||||
|
const row = document.getElementById(`row-${uid}`);
|
||||||
|
if (row) {
|
||||||
|
row.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
|
row.style.transition = 'background 0.3s';
|
||||||
|
row.style.background = 'var(--primary-light)';
|
||||||
|
setTimeout(() => { row.style.background = ''; }, 600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let saveTimer = null;
|
||||||
function onResultChange(di, ii, result) {
|
function onResultChange(di, ii, result) {
|
||||||
updateStats();
|
updateStats();
|
||||||
autoSave();
|
scheduleAutoSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRemarkChange(di, ii, value) {
|
function onRemarkChange(di, ii, value) {
|
||||||
autoSave();
|
scheduleAutoSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onVerifierChange() {
|
function onVerifierChange() {
|
||||||
updateSavedList();
|
|
||||||
autoSave();
|
|
||||||
showAutocomplete();
|
showAutocomplete();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 自动补全 ==========
|
function scheduleAutoSave() {
|
||||||
|
const name = document.getElementById("verifierName").value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
if (saveTimer) clearTimeout(saveTimer);
|
||||||
|
saveTimer = setTimeout(() => autoSave(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 自动补全(从后端加载)==========
|
||||||
let acActiveIndex = -1;
|
let acActiveIndex = -1;
|
||||||
let acItems = [];
|
let acItems = [];
|
||||||
|
let savedRecords = [];
|
||||||
|
|
||||||
function getSavedNames() {
|
async function loadSavedList() {
|
||||||
const names = [];
|
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
|
||||||
const key = localStorage.key(i);
|
|
||||||
if (key && key.startsWith(STORAGE_PREFIX)) {
|
|
||||||
const name = key.substring(STORAGE_PREFIX.length);
|
|
||||||
let meta = '';
|
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(localStorage.getItem(key) || '{}');
|
const res = await apiCall('GET', '/acceptance-tests');
|
||||||
const vals = Object.values(data.results || {});
|
savedRecords = res.data || [];
|
||||||
const pass = vals.filter(v => v === 'pass').length;
|
updateSavedSelect();
|
||||||
const total = vals.length;
|
} catch (err) { savedRecords = []; }
|
||||||
const tested = vals.filter(v => v !== 'untested').length;
|
|
||||||
meta = `${tested}/${total} 已测 · ${pass} 通过`;
|
|
||||||
} catch {}
|
|
||||||
names.push({ name, meta });
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return names.sort((a, b) => a.name.localeCompare(b.name));
|
function updateSavedSelect() {
|
||||||
|
const sel = document.getElementById("savedSelect");
|
||||||
|
const current = sel.value;
|
||||||
|
sel.innerHTML = '<option value="">-- 加载已保存 --</option>';
|
||||||
|
savedRecords.forEach(r => {
|
||||||
|
const tag = r.status === 'SUBMITTED' ? ' [已提交]' : '';
|
||||||
|
sel.innerHTML += `<option value="${r.verifierName}">${r.verifierName} (${r.testedCount}/${r.totalCount} 已测 · ${r.passCount} 通过${tag})</option>`;
|
||||||
|
});
|
||||||
|
if (current) sel.value = current;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showAutocomplete() {
|
function showAutocomplete() {
|
||||||
const input = document.getElementById('verifierName');
|
const input = document.getElementById('verifierName');
|
||||||
const list = document.getElementById('autocompleteList');
|
const list = document.getElementById('autocompleteList');
|
||||||
const q = input.value.trim().toLowerCase();
|
const q = input.value.trim().toLowerCase();
|
||||||
const saved = getSavedNames();
|
|
||||||
|
|
||||||
if (!q) {
|
if (!q) {
|
||||||
// 输入为空时显示全部已保存
|
acItems = savedRecords.map(r => ({ name: r.verifierName, meta: `${r.testedCount}/${r.totalCount} 已测 · ${r.passCount} 通过${r.status === 'SUBMITTED' ? ' · 已提交' : ''}` }));
|
||||||
acItems = saved;
|
|
||||||
} else {
|
} else {
|
||||||
// 按字母/拼音模糊匹配
|
acItems = savedRecords.filter(r => r.verifierName.toLowerCase().includes(q)).map(r => ({ name: r.verifierName, meta: `${r.testedCount}/${r.totalCount} 已测 · ${r.passCount} 通过${r.status === 'SUBMITTED' ? ' · 已提交' : ''}` }));
|
||||||
acItems = saved.filter(s => s.name.toLowerCase().includes(q));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
acActiveIndex = -1;
|
acActiveIndex = -1;
|
||||||
@@ -789,35 +1112,117 @@ function updateStats() {
|
|||||||
document.getElementById("total-partial").textContent = totalPartial;
|
document.getElementById("total-partial").textContent = totalPartial;
|
||||||
document.getElementById("total-untested").textContent = totalUntested;
|
document.getElementById("total-untested").textContent = totalUntested;
|
||||||
document.getElementById("total-rate").textContent = rate + "%";
|
document.getElementById("total-rate").textContent = rate + "%";
|
||||||
|
|
||||||
|
// Overall progress bar
|
||||||
|
const progressEl = document.getElementById("overallProgress");
|
||||||
|
if (progressEl) progressEl.style.width = rate + "%";
|
||||||
|
|
||||||
|
// Row colors
|
||||||
|
DOMAINS.forEach((domain, di) => {
|
||||||
|
domain.items.forEach((item, ii) => {
|
||||||
|
const uid = `${di}-${ii}`;
|
||||||
|
const row = document.getElementById(`row-${uid}`);
|
||||||
|
if (!row) return;
|
||||||
|
const checked2 = document.querySelector(`input[name="r-${uid}"]:checked`);
|
||||||
|
const val2 = checked2 ? checked2.value : "untested";
|
||||||
|
row.classList.remove('row-fail', 'row-partial');
|
||||||
|
if (val2 === 'fail') row.classList.add('row-fail');
|
||||||
|
else if (val2 === 'partial') row.classList.add('row-partial');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 自动计算结论
|
||||||
|
updateConclusion(totalPass, totalFail, totalPartial, totalUntested, totalItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 保存/加载 ==========
|
function updateConclusion(pass, fail, partial, untested, total) {
|
||||||
function collectData() {
|
const resultEl = document.getElementById("conclusionResult");
|
||||||
const results = {};
|
const passRateEl = document.getElementById("conclusionPassRate");
|
||||||
const remarks = {};
|
const issuesEl = document.getElementById("conclusionIssues");
|
||||||
|
const nameEl = document.getElementById("conclusionName");
|
||||||
|
const dateEl = document.getElementById("conclusionDate");
|
||||||
|
if (!resultEl) return;
|
||||||
|
|
||||||
|
const tested = pass + fail + partial;
|
||||||
|
const passRate = tested > 0 ? Math.round(pass / tested * 100) : 0;
|
||||||
|
let conclusion = "未测试", color = "var(--gray)";
|
||||||
|
|
||||||
|
if (tested === 0) { conclusion = "未测试"; color = "var(--gray)"; }
|
||||||
|
else if (fail === 0 && partial === 0) { conclusion = "✅ 通过"; color = "var(--success)"; }
|
||||||
|
else if (fail === 0 && partial > 0) { conclusion = "⚠️ 有条件通过"; color = "var(--warning)"; }
|
||||||
|
else { conclusion = "❌ 不通过"; color = "var(--danger)"; }
|
||||||
|
|
||||||
|
resultEl.textContent = conclusion;
|
||||||
|
resultEl.style.color = color;
|
||||||
|
passRateEl.textContent = tested > 0 ? `${passRate}%(${pass}/${tested})` : "-";
|
||||||
|
|
||||||
|
// 自动汇总主要问题
|
||||||
|
const issues = [];
|
||||||
DOMAINS.forEach((domain, di) => {
|
DOMAINS.forEach((domain, di) => {
|
||||||
domain.items.forEach((item, ii) => {
|
domain.items.forEach((item, ii) => {
|
||||||
const uid = `${di}-${ii}`;
|
const uid = `${di}-${ii}`;
|
||||||
const checked = document.querySelector(`input[name="r-${uid}"]:checked`);
|
const checked = document.querySelector(`input[name="r-${uid}"]:checked`);
|
||||||
results[item.id] = checked ? checked.value : "untested";
|
const val = checked ? checked.value : "untested";
|
||||||
|
const remarkEl = document.getElementById(`remark-${uid}`);
|
||||||
|
const remark = remarkEl ? remarkEl.value.trim() : "";
|
||||||
|
if (val === "fail") issues.push(`❌ [${item.id}] ${item.func}${remark ? ` — ${remark}` : ""}`);
|
||||||
|
else if (val === "partial") issues.push(`⚠️ [${item.id}] ${item.func}${remark ? ` — ${remark}` : ""}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
issuesEl.value = issues.length > 0 ? issues.join("\n") : "无主要问题";
|
||||||
|
|
||||||
|
const verifier = document.getElementById("verifierName").value.trim();
|
||||||
|
if (verifier) nameEl.value = verifier;
|
||||||
|
if (dateEl) dateEl.value = todayStr();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 数据收集/恢复 ==========
|
||||||
|
function collectData() {
|
||||||
|
const results = {};
|
||||||
|
const remarks = {};
|
||||||
|
let totalPass=0, totalFail=0, totalPartial=0, totalUntested=0, totalItems=0;
|
||||||
|
const issuesList = [];
|
||||||
|
|
||||||
|
DOMAINS.forEach((domain, di) => {
|
||||||
|
domain.items.forEach((item, ii) => {
|
||||||
|
const uid = `${di}-${ii}`;
|
||||||
|
const checked = document.querySelector(`input[name="r-${uid}"]:checked`);
|
||||||
|
const val = checked ? checked.value : "untested";
|
||||||
|
results[item.id] = val;
|
||||||
const remarkEl = document.getElementById(`remark-${uid}`);
|
const remarkEl = document.getElementById(`remark-${uid}`);
|
||||||
remarks[item.id] = remarkEl ? remarkEl.value : "";
|
remarks[item.id] = remarkEl ? remarkEl.value : "";
|
||||||
|
|
||||||
|
if (val==="pass") totalPass++;
|
||||||
|
else if (val==="fail") { totalFail++; issuesList.push(`❌ [${item.id}] ${item.func}${remarkEl?.value.trim() ? ` — ${remarkEl.value.trim()}` : ""}`); }
|
||||||
|
else if (val==="partial") { totalPartial++; issuesList.push(`⚠️ [${item.id}] ${item.func}${remarkEl?.value.trim() ? ` — ${remarkEl.value.trim()}` : ""}`); }
|
||||||
|
else totalUntested++;
|
||||||
|
totalItems++;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tested = totalPass + totalFail + totalPartial;
|
||||||
|
let conclusionResult = "";
|
||||||
|
if (tested === 0) conclusionResult = "";
|
||||||
|
else if (totalFail === 0 && totalPartial === 0) conclusionResult = "pass";
|
||||||
|
else if (totalFail === 0 && totalPartial > 0) conclusionResult = "conditional";
|
||||||
|
else conclusionResult = "fail";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
verifier: document.getElementById("verifierName").value,
|
verifier: document.getElementById("verifierName").value,
|
||||||
results, remarks,
|
results, remarks,
|
||||||
conclusionName: document.getElementById("conclusionName")?.value || "",
|
conclusionName: document.getElementById("conclusionName")?.value || "",
|
||||||
conclusionDate: document.getElementById("conclusionDate")?.value || "",
|
conclusionDate: document.getElementById("conclusionDate")?.value || "",
|
||||||
conclusionResult: document.querySelector('input[name="conclusion"]:checked')?.value || "",
|
conclusionResult,
|
||||||
conclusionIssues: document.getElementById("conclusionIssues")?.value || "",
|
conclusionIssues: issuesList.join("\n"),
|
||||||
savedAt: new Date().toISOString(),
|
screenshots,
|
||||||
|
signature1: getSigData(1),
|
||||||
|
signature2: getSigData(2),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyData(data) {
|
function applyData(data) {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
document.getElementById("verifierName").value = data.verifier || "";
|
document.getElementById("verifierName").value = data.verifierName || data.verifier || "";
|
||||||
DOMAINS.forEach((domain, di) => {
|
DOMAINS.forEach((domain, di) => {
|
||||||
domain.items.forEach((item, ii) => {
|
domain.items.forEach((item, ii) => {
|
||||||
const uid = `${di}-${ii}`;
|
const uid = `${di}-${ii}`;
|
||||||
@@ -830,87 +1235,109 @@ function applyData(data) {
|
|||||||
});
|
});
|
||||||
if (data.conclusionName) document.getElementById("conclusionName").value = data.conclusionName;
|
if (data.conclusionName) document.getElementById("conclusionName").value = data.conclusionName;
|
||||||
if (data.conclusionDate) document.getElementById("conclusionDate").value = data.conclusionDate;
|
if (data.conclusionDate) document.getElementById("conclusionDate").value = data.conclusionDate;
|
||||||
if (data.conclusionResult) {
|
|
||||||
const r = document.querySelector(`input[name="conclusion"][value="${data.conclusionResult}"]`);
|
// Restore screenshots
|
||||||
if (r) r.checked = true;
|
if (data.screenshots) {
|
||||||
|
Object.assign(screenshots, data.screenshots);
|
||||||
|
DOMAINS.forEach((domain, di) => {
|
||||||
|
domain.items.forEach((item, ii) => {
|
||||||
|
const uid = `${di}-${ii}`;
|
||||||
|
if (screenshots[uid]) {
|
||||||
|
const cell = document.querySelector(`#row-${uid} .screenshot-cell`);
|
||||||
|
if (cell) cell.innerHTML = `<img class="screenshot-thumb" src="${screenshots[uid]}" onclick="viewScreenshot('${uid}')" title="点击查看">`;
|
||||||
}
|
}
|
||||||
if (data.conclusionIssues) document.getElementById("conclusionIssues").value = data.conclusionIssues;
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
updateStats();
|
updateStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStorageKey() {
|
// ========== 保存/加载/提交(API)==========
|
||||||
const name = document.getElementById("verifierName").value.trim();
|
async function saveCurrent() {
|
||||||
return name ? STORAGE_PREFIX + name : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveCurrent() {
|
|
||||||
const name = document.getElementById("verifierName").value.trim();
|
const name = document.getElementById("verifierName").value.trim();
|
||||||
if (!name) { showToast("请先输入验收人姓名"); return; }
|
if (!name) { showToast("请先输入验收人姓名"); return; }
|
||||||
const data = collectData();
|
const data = collectData();
|
||||||
localStorage.setItem(STORAGE_PREFIX + name, JSON.stringify(data));
|
try {
|
||||||
updateSavedList();
|
await apiCall('POST', '/acceptance-tests', {
|
||||||
|
verifierName: name, results: data.results, remarks: data.remarks,
|
||||||
|
conclusionName: data.conclusionName || name, conclusionDate: data.conclusionDate,
|
||||||
|
conclusionResult: data.conclusionResult, conclusionIssues: data.conclusionIssues,
|
||||||
|
screenshots: data.screenshots, signature1: data.signature1, signature2: data.signature2,
|
||||||
|
});
|
||||||
|
await loadSavedList();
|
||||||
document.getElementById("savedSelect").value = name;
|
document.getElementById("savedSelect").value = name;
|
||||||
showToast(`已保存:${name}`);
|
showToast(`✅ 已保存:${name}`);
|
||||||
|
} catch (err) { showToast(`保存失败:${err.message}`); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function autoSave() {
|
async function autoSave() {
|
||||||
const name = document.getElementById("verifierName").value.trim();
|
const name = document.getElementById("verifierName").value.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
const data = collectData();
|
const data = collectData();
|
||||||
localStorage.setItem(STORAGE_PREFIX + name, JSON.stringify(data));
|
try {
|
||||||
|
await apiCall('POST', '/acceptance-tests', {
|
||||||
|
verifierName: name, results: data.results, remarks: data.remarks,
|
||||||
|
conclusionName: data.conclusionName || name, conclusionDate: data.conclusionDate,
|
||||||
|
conclusionResult: data.conclusionResult, conclusionIssues: data.conclusionIssues,
|
||||||
|
screenshots: data.screenshots, signature1: data.signature1, signature2: data.signature2,
|
||||||
|
});
|
||||||
|
await loadSavedList();
|
||||||
|
} catch (err) { console.warn('自动保存失败:', err.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadSaved(name) {
|
async function loadSaved(name) {
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
const raw = localStorage.getItem(STORAGE_PREFIX + name);
|
try {
|
||||||
if (!raw) { showToast("未找到保存记录"); return; }
|
const res = await apiCall('GET', `/acceptance-tests/${encodeURIComponent(name)}`);
|
||||||
applyData(JSON.parse(raw));
|
applyData(res.data);
|
||||||
showToast(`已加载:${name}`);
|
showToast(`已加载:${name}`);
|
||||||
|
} catch (err) { showToast(`加载失败:${err.message}`); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteSaved() {
|
async function deleteSaved() {
|
||||||
const name = document.getElementById("verifierName").value.trim();
|
const name = document.getElementById("verifierName").value.trim();
|
||||||
const sel = document.getElementById("savedSelect").value;
|
const sel = document.getElementById("savedSelect").value;
|
||||||
const target = name || sel;
|
const target = name || sel;
|
||||||
if (!target) { showToast("请先选择要删除的记录"); return; }
|
if (!target) { showToast("请先选择要删除的记录"); return; }
|
||||||
if (!confirm(`确认删除「${target}」的验收记录?`)) return;
|
if (!confirm(`确认删除「${target}」的验收记录?`)) return;
|
||||||
localStorage.removeItem(STORAGE_PREFIX + target);
|
try {
|
||||||
updateSavedList();
|
await apiCall('DELETE', `/acceptance-tests/${encodeURIComponent(target)}`);
|
||||||
|
await loadSavedList();
|
||||||
if (target === name) document.getElementById("verifierName").value = "";
|
if (target === name) document.getElementById("verifierName").value = "";
|
||||||
showToast(`已删除:${target}`);
|
showToast(`已删除:${target}`);
|
||||||
|
} catch (err) { showToast(`删除失败:${err.message}`); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteSavedByName(name) {
|
async function deleteSavedByName(name) {
|
||||||
if (!confirm(`确认删除「${name}」的验收记录?`)) return;
|
if (!confirm(`确认删除「${name}」的验收记录?`)) return;
|
||||||
localStorage.removeItem(STORAGE_PREFIX + name);
|
try {
|
||||||
updateSavedList();
|
await apiCall('DELETE', `/acceptance-tests/${encodeURIComponent(name)}`);
|
||||||
|
await loadSavedList();
|
||||||
showAutocomplete();
|
showAutocomplete();
|
||||||
showToast(`已删除:${name}`);
|
showToast(`已删除:${name}`);
|
||||||
|
} catch (err) { showToast(`删除失败:${err.message}`); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearAllSaved() {
|
async function submitReport() {
|
||||||
const keys = Object.keys(localStorage).filter(k => k.startsWith(STORAGE_PREFIX));
|
const name = document.getElementById("verifierName").value.trim();
|
||||||
if (keys.length === 0) { showToast("无已保存记录"); return; }
|
if (!name) { showToast("请先输入验收人姓名"); return; }
|
||||||
if (!confirm(`确认清空全部 ${keys.length} 条验收记录?此操作不可恢复!`)) return;
|
const data = collectData();
|
||||||
keys.forEach(k => localStorage.removeItem(k));
|
if (!data.conclusionResult) { showToast("请先完成至少一项测试"); return; }
|
||||||
updateSavedList();
|
if (!confirm(`确认提交「${name}」的验收报告?提交后不可再编辑。`)) return;
|
||||||
showAutocomplete();
|
try {
|
||||||
document.getElementById("verifierName").value = "";
|
await apiCall('POST', '/acceptance-tests', {
|
||||||
showToast(`已清空 ${keys.length} 条记录`);
|
verifierName: name, results: data.results, remarks: data.remarks,
|
||||||
}
|
conclusionName: data.conclusionName || name, conclusionDate: data.conclusionDate,
|
||||||
|
conclusionResult: data.conclusionResult, conclusionIssues: data.conclusionIssues,
|
||||||
function updateSavedList() {
|
screenshots: data.screenshots, signature1: data.signature1, signature2: data.signature2,
|
||||||
const sel = document.getElementById("savedSelect");
|
});
|
||||||
const current = sel.value;
|
await apiCall('POST', `/acceptance-tests/${encodeURIComponent(name)}/submit`, {
|
||||||
sel.innerHTML = '<option value="">-- 加载已保存 --</option>';
|
conclusionResult: data.conclusionResult, conclusionIssues: data.conclusionIssues,
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
});
|
||||||
const key = localStorage.key(i);
|
await loadSavedList();
|
||||||
if (key && key.startsWith(STORAGE_PREFIX)) {
|
showToast(`✅ 验收报告已提交:${name}`);
|
||||||
const name = key.substring(STORAGE_PREFIX.length);
|
} catch (err) { showToast(`提交失败:${err.message}`); }
|
||||||
sel.innerHTML += `<option value="${name}">${name}</option>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (current) sel.value = current;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 下载 ==========
|
// ========== 下载 ==========
|
||||||
@@ -1003,17 +1430,125 @@ function todayStr() {
|
|||||||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
|
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 截图上传 ==========
|
||||||
|
const screenshots = {}; // { "di-ii": dataURL }
|
||||||
|
|
||||||
|
function uploadScreenshot(di, ii) {
|
||||||
|
const uid = `${di}-${ii}`;
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = 'image/*';
|
||||||
|
input.onchange = (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
screenshots[uid] = ev.target.result;
|
||||||
|
const cell = document.querySelector(`#row-${uid} .screenshot-cell`);
|
||||||
|
if (cell) cell.innerHTML = `<img class="screenshot-thumb" src="${ev.target.result}" onclick="viewScreenshot('${uid}')" title="点击查看">`;
|
||||||
|
scheduleAutoSave();
|
||||||
|
showToast("截图已上传");
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewScreenshot(uid) {
|
||||||
|
const data = screenshots[uid];
|
||||||
|
if (!data) return;
|
||||||
|
const w = window.open('', '_blank');
|
||||||
|
w.document.write(`<html><head><title>截图查看</title></head><body style="margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#1f2937"><img src="${data}" style="max-width:100%;max-height:100vh"></body></html>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 手写签名 ==========
|
||||||
|
let sigCtx1, sigCtx2, sigDrawing = false;
|
||||||
|
|
||||||
|
function initSignatures() {
|
||||||
|
[1, 2].forEach(n => {
|
||||||
|
const canvas = document.getElementById(`sigCanvas${n}`);
|
||||||
|
if (!canvas) return;
|
||||||
|
const box = document.getElementById(`sigBox${n}`);
|
||||||
|
canvas.width = box.offsetWidth;
|
||||||
|
canvas.height = 80;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.strokeStyle = '#1f2937';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
|
||||||
|
const getPos = (e) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
|
||||||
|
const y = (e.touches ? e.touches[0].clientY : e.clientY) - rect.top;
|
||||||
|
return { x, y };
|
||||||
|
};
|
||||||
|
|
||||||
|
const start = (e) => { e.preventDefault(); sigDrawing = true; const p = getPos(e); ctx.beginPath(); ctx.moveTo(p.x, p.y); };
|
||||||
|
const move = (e) => { if (!sigDrawing) return; e.preventDefault(); const p = getPos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); };
|
||||||
|
const end = () => { sigDrawing = false; };
|
||||||
|
|
||||||
|
canvas.addEventListener('mousedown', start);
|
||||||
|
canvas.addEventListener('mousemove', move);
|
||||||
|
canvas.addEventListener('mouseup', end);
|
||||||
|
canvas.addEventListener('mouseleave', end);
|
||||||
|
canvas.addEventListener('touchstart', start);
|
||||||
|
canvas.addEventListener('touchmove', move);
|
||||||
|
canvas.addEventListener('touchend', end);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSig(n) {
|
||||||
|
const canvas = document.getElementById(`sigCanvas${n}`);
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSigData(n) {
|
||||||
|
const canvas = document.getElementById(`sigCanvas${n}`);
|
||||||
|
if (!canvas) return '';
|
||||||
|
// Check if canvas is empty
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||||
|
const isEmpty = !data.some((v, i) => i % 4 === 3 && v > 0);
|
||||||
|
return isEmpty ? '' : canvas.toDataURL();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== PDF 导出(打印优化)==========
|
||||||
|
function exportPDF() {
|
||||||
|
const data = collectData();
|
||||||
|
if (!data.verifier) { showToast("请先输入验收人姓名"); return; }
|
||||||
|
|
||||||
|
// 临时展开所有折叠的域
|
||||||
|
const collapsed = [];
|
||||||
|
DOMAINS.forEach((_, di) => {
|
||||||
|
const el = document.getElementById(`domain-${di}`);
|
||||||
|
if (el && el.classList.contains('collapsed')) { collapsed.push(di); el.classList.remove('collapsed'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置打印模式
|
||||||
|
document.body.classList.add('print-mode');
|
||||||
|
window.print();
|
||||||
|
|
||||||
|
// 恢复
|
||||||
|
document.body.classList.remove('print-mode');
|
||||||
|
collapsed.forEach(di => {
|
||||||
|
const el = document.getElementById(`domain-${di}`);
|
||||||
|
if (el) { el.classList.add('collapsed'); el.querySelector(".toggle").textContent = "▶"; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Toast ==========
|
// ========== Toast ==========
|
||||||
function showToast(msg) {
|
function showToast(msg) {
|
||||||
const t = document.getElementById("toast");
|
const t = document.getElementById("toast");
|
||||||
t.textContent = msg;
|
t.textContent = msg;
|
||||||
t.classList.add("show");
|
t.classList.add("show");
|
||||||
setTimeout(() => t.classList.remove("show"), 2000);
|
setTimeout(() => t.classList.remove("show"), 2500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 初始化 ==========
|
// ========== 初始化 ==========
|
||||||
renderAll();
|
renderAll();
|
||||||
updateSavedList();
|
loadSavedList();
|
||||||
|
|
||||||
// 自动填充验收日期
|
// 自动填充验收日期
|
||||||
const dateEl = document.getElementById("conclusionDate");
|
const dateEl = document.getElementById("conclusionDate");
|
||||||
|
|||||||
@@ -40,8 +40,10 @@ const AnnualValueReport = lazy(() => import('./pages/tools/AnnualValueReport'))
|
|||||||
const CalendarPage = lazy(() => import('./pages/Calendar'))
|
const CalendarPage = lazy(() => import('./pages/Calendar'))
|
||||||
const WorkProcess = lazy(() => import('./pages/WorkProcess'))
|
const WorkProcess = lazy(() => import('./pages/WorkProcess'))
|
||||||
const MyAttendance = lazy(() => import('./pages/portal/MyAttendance'))
|
const MyAttendance = lazy(() => import('./pages/portal/MyAttendance'))
|
||||||
|
const MyLeave = lazy(() => import('./pages/portal/MyLeave'))
|
||||||
const SpecialStatus = lazy(() => import('./pages/SpecialStatus'))
|
const SpecialStatus = lazy(() => import('./pages/SpecialStatus'))
|
||||||
const CompanyFiles = lazy(() => import('./pages/CompanyFiles'))
|
const CompanyFiles = lazy(() => import('./pages/CompanyFiles'))
|
||||||
|
const LeaveApproval = lazy(() => import('./pages/LeaveApproval'))
|
||||||
|
|
||||||
// Sprint 4-5 新增页面
|
// Sprint 4-5 新增页面
|
||||||
const EmployeeHome = lazy(() => import('./pages/portal/EmployeeHome'))
|
const EmployeeHome = lazy(() => import('./pages/portal/EmployeeHome'))
|
||||||
@@ -191,6 +193,7 @@ export default function App() {
|
|||||||
<Route path="/work-process" element={<ProtectedRoute><AdminLayout><WorkProcess /></AdminLayout></ProtectedRoute>} />
|
<Route path="/work-process" element={<ProtectedRoute><AdminLayout><WorkProcess /></AdminLayout></ProtectedRoute>} />
|
||||||
<Route path="/special-status" element={<ProtectedRoute><AdminLayout><SpecialStatus /></AdminLayout></ProtectedRoute>} />
|
<Route path="/special-status" element={<ProtectedRoute><AdminLayout><SpecialStatus /></AdminLayout></ProtectedRoute>} />
|
||||||
<Route path="/company-files" element={<ProtectedRoute><AdminLayout><CompanyFiles /></AdminLayout></ProtectedRoute>} />
|
<Route path="/company-files" element={<ProtectedRoute><AdminLayout><CompanyFiles /></AdminLayout></ProtectedRoute>} />
|
||||||
|
<Route path="/leave-approval" element={<ProtectedRoute><AdminLayout><LeaveApproval /></AdminLayout></ProtectedRoute>} />
|
||||||
<Route path="/risk-center" element={<ProtectedRoute><AdminLayout><RiskCenter /></AdminLayout></ProtectedRoute>} />
|
<Route path="/risk-center" element={<ProtectedRoute><AdminLayout><RiskCenter /></AdminLayout></ProtectedRoute>} />
|
||||||
<Route path="/salary-dashboard" element={<ProtectedRoute><AdminLayout><SalaryDashboard /></AdminLayout></ProtectedRoute>} />
|
<Route path="/salary-dashboard" element={<ProtectedRoute><AdminLayout><SalaryDashboard /></AdminLayout></ProtectedRoute>} />
|
||||||
|
|
||||||
@@ -208,6 +211,7 @@ export default function App() {
|
|||||||
<Route path="/portal/contract-confirm" element={<PortalLayoutWrapper showNav={false}><ContractConfirm /></PortalLayoutWrapper>} />
|
<Route path="/portal/contract-confirm" element={<PortalLayoutWrapper showNav={false}><ContractConfirm /></PortalLayoutWrapper>} />
|
||||||
<Route path="/portal/policies" element={<PortalLayoutWrapper><MyPolicies /></PortalLayoutWrapper>} />
|
<Route path="/portal/policies" element={<PortalLayoutWrapper><MyPolicies /></PortalLayoutWrapper>} />
|
||||||
<Route path="/portal/attendance" element={<PortalLayoutWrapper><MyAttendance /></PortalLayoutWrapper>} />
|
<Route path="/portal/attendance" element={<PortalLayoutWrapper><MyAttendance /></PortalLayoutWrapper>} />
|
||||||
|
<Route path="/portal/leave" element={<PortalLayoutWrapper><MyLeave /></PortalLayoutWrapper>} />
|
||||||
<Route path="/portal/auto-login" element={<PortalLayoutWrapper showNav={false}><AutoLogin /></PortalLayoutWrapper>} />
|
<Route path="/portal/auto-login" element={<PortalLayoutWrapper showNav={false}><AutoLogin /></PortalLayoutWrapper>} />
|
||||||
<Route path="/portal/home" element={<PortalLayoutWrapper><EmployeeHome /></PortalLayoutWrapper>} />
|
<Route path="/portal/home" element={<PortalLayoutWrapper><EmployeeHome /></PortalLayoutWrapper>} />
|
||||||
<Route path="/portal/onboarding-progress" element={<PortalLayoutWrapper><OnboardingProgress /></PortalLayoutWrapper>} />
|
<Route path="/portal/onboarding-progress" element={<PortalLayoutWrapper><OnboardingProgress /></PortalLayoutWrapper>} />
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ export default function AcceptanceTestModal({ open, onClose }: { open: boolean;
|
|||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* iframe 加载验收测试页面 */}
|
{/* iframe 加载验收测试页面 — 带版本参数防止缓存 */}
|
||||||
<iframe
|
<iframe
|
||||||
src="/acceptance-test.html"
|
src={`/acceptance-test.html?v=${__APP_VERSION__}`}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
style={{ height: 'calc(95vh - 42px)', border: 'none' }}
|
style={{ height: 'calc(95vh - 42px)', border: 'none' }}
|
||||||
title="验收测试清单"
|
title="验收测试清单"
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
import { Component, ErrorInfo, ReactNode } from 'react'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
hasError: boolean
|
|
||||||
error: Error | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class ErrorBoundary extends Component<Props, State> {
|
|
||||||
constructor(props: Props) {
|
|
||||||
super(props)
|
|
||||||
this.state = { hasError: false, error: null }
|
|
||||||
}
|
|
||||||
|
|
||||||
static getDerivedStateFromError(error: Error): State {
|
|
||||||
return { hasError: true, error }
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
||||||
console.error('ErrorBoundary caught:', error, errorInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
handleReset = () => {
|
|
||||||
this.setState({ hasError: false, error: null })
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (this.state.hasError) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-[60vh] flex flex-col items-center justify-center gap-4 px-4">
|
|
||||||
<div className="text-6xl">😵</div>
|
|
||||||
<h2 className="text-xl font-semibold text-gray-800">页面出错了</h2>
|
|
||||||
<p className="text-sm text-gray-500 text-center max-w-md">
|
|
||||||
{this.state.error?.message || '发生了未知错误,请刷新页面重试'}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={this.handleReset}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
|
||||||
>
|
|
||||||
重试
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
刷新页面
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.props.children
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { HelpCircle, Search, ChevronDown, ChevronRight, Sparkles,
|
import { HelpCircle, Search, ChevronDown, ChevronRight, Sparkles,
|
||||||
Home, Users, FileText, Calculator, Shield, UserX, Bot,
|
Home, Users, FileText, Calculator, Bot,
|
||||||
Bell, Settings, Lightbulb, AlertTriangle, CheckCircle, Phone } from 'lucide-react'
|
Settings, Lightbulb, AlertTriangle, CheckCircle, Phone } from 'lucide-react'
|
||||||
import Modal from './ui/Modal'
|
import Modal from './ui/Modal'
|
||||||
import { aiApi } from '../lib/api-services'
|
import { aiApi } from '../lib/api-services'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo, useEffect, useCallback } from 'react'
|
import { useState, useMemo, useEffect, useCallback } from 'react'
|
||||||
import { Star, ChevronDown, ChevronRight, Check, Search, ClipboardList, Send, Save, RotateCcw } from 'lucide-react'
|
import { Star, ChevronDown, ChevronRight, Search, ClipboardList, Send, Save, RotateCcw } from 'lucide-react'
|
||||||
import Modal from './ui/Modal'
|
import Modal from './ui/Modal'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { surveyApi } from '../lib/api-services'
|
import { surveyApi } from '../lib/api-services'
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const ROUTE_MAP: Record<string, BreadcrumbItem> = {
|
|||||||
'/roster': { group: '员工管理', label: '花名册' },
|
'/roster': { group: '员工管理', label: '花名册' },
|
||||||
'/work-process': { group: '员工管理', label: '用工办理' },
|
'/work-process': { group: '员工管理', label: '用工办理' },
|
||||||
'/attendance': { group: '员工管理', label: '考勤确认' },
|
'/attendance': { group: '员工管理', label: '考勤确认' },
|
||||||
|
'/leave-approval': { group: '员工管理', label: '休假审批' },
|
||||||
'/termination': { group: '员工管理', label: '解聘补偿' },
|
'/termination': { group: '员工管理', label: '解聘补偿' },
|
||||||
'/special-status': { group: '员工管理', label: '特殊状态' },
|
'/special-status': { group: '员工管理', label: '特殊状态' },
|
||||||
'/money': { group: '薪税社保', label: '薪税管理' },
|
'/money': { group: '薪税社保', label: '薪税管理' },
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { DollarSign, FileText, ScrollText, LogOut, CalendarCheck, Home, UserX, ClipboardList } from 'lucide-react'
|
import { DollarSign, FileText, ScrollText, LogOut, CalendarCheck, Home } from 'lucide-react'
|
||||||
import Logo from '../../components/ui/Logo'
|
import Logo from '../../components/ui/Logo'
|
||||||
|
|
||||||
const tabItems = [
|
const tabItems = [
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
Bot, BookMarked,
|
Bot, BookMarked,
|
||||||
Bell, ScrollText, Settings,
|
Bell, ScrollText, Settings,
|
||||||
ChevronDown, ChevronRight,
|
ChevronDown, ChevronRight,
|
||||||
Building2, CalendarDays, ClipboardList, Heart,
|
Building2, CalendarDays, ClipboardList, Heart, CalendarClock,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Logo from '../ui/Logo'
|
import Logo from '../ui/Logo'
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ const navGroups: NavGroup[] = [
|
|||||||
title: '首页',
|
title: '首页',
|
||||||
items: [
|
items: [
|
||||||
{ path: '/', label: '工作台', icon: LayoutDashboard },
|
{ path: '/', label: '工作台', icon: LayoutDashboard },
|
||||||
{ path: '/calendar', label: '工作日历', icon: CalendarDays },
|
{ path: '/calendar', label: '日历', icon: CalendarDays },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -57,6 +57,7 @@ const navGroups: NavGroup[] = [
|
|||||||
title: '时间',
|
title: '时间',
|
||||||
items: [
|
items: [
|
||||||
{ path: '/attendance', label: '考勤排班', icon: CalendarCheck },
|
{ path: '/attendance', label: '考勤排班', icon: CalendarCheck },
|
||||||
|
{ path: '/leave-approval', label: '休假审批', icon: CalendarClock },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -66,7 +67,7 @@ const navGroups: NavGroup[] = [
|
|||||||
{ path: '/evidence', label: '证据链', icon: FileSearch },
|
{ path: '/evidence', label: '证据链', icon: FileSearch },
|
||||||
{ path: '/policies', label: '规章制度', icon: FileText },
|
{ path: '/policies', label: '规章制度', icon: FileText },
|
||||||
{ path: '/tools/health-check', label: '用工体检', icon: Stethoscope },
|
{ path: '/tools/health-check', label: '用工体检', icon: Stethoscope },
|
||||||
{ path: '/tools/medical-period', label: '医疗期计算', icon: HeartPulse },
|
{ path: '/tools/medical-period', label: '医疗期', icon: HeartPulse },
|
||||||
{ path: '/tools/annual-value', label: '年度价值', icon: Award },
|
{ path: '/tools/annual-value', label: '年度价值', icon: Award },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const QUICK_PAGES: SearchResult[] = [
|
|||||||
{ type: 'page', id: 'social', title: '社保公积金', link: '/social', icon: 'shield' },
|
{ type: 'page', id: 'social', title: '社保公积金', link: '/social', icon: 'shield' },
|
||||||
{ type: 'page', id: 'termination', title: '离职管理', link: '/termination', icon: 'userX' },
|
{ type: 'page', id: 'termination', title: '离职管理', link: '/termination', icon: 'userX' },
|
||||||
{ type: 'page', id: 'attendance', title: '考勤排班', link: '/attendance', icon: 'calendar' },
|
{ type: 'page', id: 'attendance', title: '考勤排班', link: '/attendance', icon: 'calendar' },
|
||||||
|
{ type: 'page', id: 'leave-approval', title: '休假审批', link: '/leave-approval', icon: 'calendar' },
|
||||||
{ type: 'page', id: 'risk-center', title: '风险中心', link: '/risk-center', icon: 'alert' },
|
{ type: 'page', id: 'risk-center', title: '风险中心', link: '/risk-center', icon: 'alert' },
|
||||||
{ type: 'page', id: 'salary-dashboard', title: '薪酬分析', link: '/salary-dashboard', icon: 'chart' },
|
{ type: 'page', id: 'salary-dashboard', title: '薪酬分析', link: '/salary-dashboard', icon: 'chart' },
|
||||||
{ type: 'page', id: 'policies', title: '规章制度', link: '/policies', icon: 'file' },
|
{ type: 'page', id: 'policies', title: '规章制度', link: '/policies', icon: 'file' },
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ export default class ErrorBoundary extends Component<Props, State> {
|
|||||||
return { hasError: true, error }
|
return { hasError: true, error }
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
componentDidCatch(_error: Error, _errorInfo: React.ErrorInfo) {
|
||||||
console.error('ErrorBoundary caught:', error, errorInfo)
|
// 错误已通过 getDerivedStateFromError 捕获并展示降级 UI
|
||||||
}
|
}
|
||||||
|
|
||||||
handleReset = () => {
|
handleReset = () => {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* PageGuide 页面操作指导组件 — 折叠式,默认收起
|
||||||
|
* 统一用于各页面/Tab的操作说明,点击展开查看
|
||||||
|
*/
|
||||||
|
import { useState, ReactNode } from 'react'
|
||||||
|
import { ChevronRight, Lightbulb } from 'lucide-react'
|
||||||
|
|
||||||
|
interface PageGuideProps {
|
||||||
|
/** 指导标题,默认"操作说明" */
|
||||||
|
title?: string
|
||||||
|
/** 指导内容,支持字符串或自定义JSX */
|
||||||
|
children: ReactNode
|
||||||
|
/** 额外类名 */
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageGuide({ title = '操作说明', children, className = '' }: PageGuideProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-md border border-blue-100 bg-blue-50/50 ${className}`}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="flex items-center gap-1.5 w-full px-3 py-1.5 text-xs font-medium text-blue-600 hover:text-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight className={`w-3 h-3 transition-transform ${open ? 'rotate-90' : ''}`} />
|
||||||
|
<Lightbulb className="w-3.5 h-3.5" />
|
||||||
|
{title}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="px-3 pb-2.5 text-xs text-blue-600/80 leading-relaxed">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { AlertCircle, RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
|
interface QueryErrorProps {
|
||||||
|
error?: unknown
|
||||||
|
onRetry?: () => void
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询错误状态组件
|
||||||
|
* 在 useQuery 的 isError 状态下显示错误提示和重试按钮
|
||||||
|
*/
|
||||||
|
export default function QueryError({ error, onRetry, message }: QueryErrorProps) {
|
||||||
|
const errMsg = message
|
||||||
|
|| (error as any)?.response?.data?.error?.message
|
||||||
|
|| (error as any)?.message
|
||||||
|
|| '数据加载失败,请稍后重试'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-red-50 flex items-center justify-center mb-3">
|
||||||
|
<AlertCircle className="w-6 h-6 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 mb-3 max-w-md">{errMsg}</p>
|
||||||
|
{onRetry && (
|
||||||
|
<button
|
||||||
|
onClick={onRetry}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-primary bg-primary/5 rounded-lg hover:bg-primary/10 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
* Stepper 步骤条组件 — 用于多步骤工作流引导
|
* Stepper 步骤条组件 — 用于多步骤工作流引导
|
||||||
* 支持横向/纵向布局、可点击步骤导航、完成/当前/待办状态
|
* 支持横向/纵向布局、可点击步骤导航、完成/当前/待办状态
|
||||||
*/
|
*/
|
||||||
import { ReactNode } from 'react'
|
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { Check } from 'lucide-react'
|
import { Check } from 'lucide-react'
|
||||||
|
|
||||||
@@ -24,7 +23,6 @@ interface StepperProps {
|
|||||||
* Stepper 步骤条 — 展示工作流进度和步骤导航
|
* Stepper 步骤条 — 展示工作流进度和步骤导航
|
||||||
*/
|
*/
|
||||||
export function Stepper({ steps, orientation = 'horizontal', onStepClick, className }: StepperProps) {
|
export function Stepper({ steps, orientation = 'horizontal', onStepClick, className }: StepperProps) {
|
||||||
const currentIndex = steps.findIndex(s => s.status === 'current')
|
|
||||||
|
|
||||||
if (orientation === 'vertical') {
|
if (orientation === 'vertical') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ export const authApi = {
|
|||||||
login: (data: { phone: string; password: string }) =>
|
login: (data: { phone: string; password: string }) =>
|
||||||
post('/auth/login', data).then(unwrap<any>()),
|
post('/auth/login', data).then(unwrap<any>()),
|
||||||
/** 注册 */
|
/** 注册 */
|
||||||
register: (data: any) =>
|
register: (data: Record<string, unknown>) =>
|
||||||
post('/auth/register', data).then(unwrap<any>()),
|
post('/auth/register', data).then(unwrap<any>()),
|
||||||
/** 平台登录 */
|
/** 平台登录 */
|
||||||
platformLogin: (data: any) =>
|
platformLogin: (data: Record<string, unknown>) =>
|
||||||
post('/auth/platform-login', data).then(unwrap<any>()),
|
post('/auth/platform-login', data).then(unwrap<any>()),
|
||||||
/** 发送忘记密码验证码 */
|
/** 发送忘记密码验证码 */
|
||||||
forgotPasswordSendCode: (phone: string) =>
|
forgotPasswordSendCode: (phone: string) =>
|
||||||
@@ -64,19 +64,19 @@ export const employeeApi = {
|
|||||||
detail: (id: string) =>
|
detail: (id: string) =>
|
||||||
get(`/employees/${id}`).then(unwrap<any>()),
|
get(`/employees/${id}`).then(unwrap<any>()),
|
||||||
/** 创建员工 */
|
/** 创建员工 */
|
||||||
create: (data: any) =>
|
create: (data: Record<string, unknown>) =>
|
||||||
post('/employees', data),
|
post('/employees', data),
|
||||||
/** 更新员工 */
|
/** 更新员工 */
|
||||||
update: (id: string, data: any) =>
|
update: (id: string, data: Record<string, unknown>) =>
|
||||||
put(`/employees/${id}`, data),
|
put(`/employees/${id}`, data),
|
||||||
/** 删除员工 */
|
/** 删除员工 */
|
||||||
remove: (id: string) =>
|
remove: (id: string) =>
|
||||||
del(`/employees/${id}`),
|
del(`/employees/${id}`),
|
||||||
/** 重新入职 */
|
/** 重新入职 */
|
||||||
rehire: (id: string, data: any) =>
|
rehire: (id: string, data: Record<string, unknown>) =>
|
||||||
post(`/employees/${id}/rehire`, data),
|
post(`/employees/${id}/rehire`, data),
|
||||||
/** 添加合同 */
|
/** 添加合同 */
|
||||||
addContract: (data: any) =>
|
addContract: (data: Record<string, unknown>) =>
|
||||||
post('/employees/contracts', data),
|
post('/employees/contracts', data),
|
||||||
/** 删除合同 */
|
/** 删除合同 */
|
||||||
removeContract: (contractId: string) =>
|
removeContract: (contractId: string) =>
|
||||||
@@ -101,7 +101,7 @@ export interface RosterParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RosterResponse {
|
export interface RosterResponse {
|
||||||
data: any[]
|
data: Record<string, unknown>[]
|
||||||
pagination: { page: number; pageSize: number; total: number; totalPages: number }
|
pagination: { page: number; pageSize: number; total: number; totalPages: number }
|
||||||
globalRiskStats?: { expiring: number; expired: number; unsigned: number }
|
globalRiskStats?: { expiring: number; expired: number; unsigned: number }
|
||||||
}
|
}
|
||||||
@@ -129,31 +129,31 @@ export const rosterApi = {
|
|||||||
profile: (employeeId: string) =>
|
profile: (employeeId: string) =>
|
||||||
get(`/roster/${employeeId}/profile`).then(unwrap<any>()),
|
get(`/roster/${employeeId}/profile`).then(unwrap<any>()),
|
||||||
/** 调薪 */
|
/** 调薪 */
|
||||||
salaryChange: (employeeId: string, data: any) =>
|
salaryChange: (employeeId: string, data: Record<string, unknown>) =>
|
||||||
post(`/roster/${employeeId}/salary-change`, data),
|
post(`/roster/${employeeId}/salary-change`, data),
|
||||||
/** 调岗 */
|
/** 调岗 */
|
||||||
departmentChange: (employeeId: string, data: any) =>
|
departmentChange: (employeeId: string, data: Record<string, unknown>) =>
|
||||||
post(`/roster/${employeeId}/department-change`, data),
|
post(`/roster/${employeeId}/department-change`, data),
|
||||||
/** 考勤记录 */
|
/** 考勤记录 */
|
||||||
attendance: (employeeId: string, data: any) =>
|
attendance: (employeeId: string, data: Record<string, unknown>) =>
|
||||||
post(`/roster/${employeeId}/attendance`, data),
|
post(`/roster/${employeeId}/attendance`, data),
|
||||||
/** 删除考勤记录 */
|
/** 删除考勤记录 */
|
||||||
removeAttendance: (employeeId: string, id: string) =>
|
removeAttendance: (employeeId: string, id: string) =>
|
||||||
del(`/roster/${employeeId}/attendance/${id}`),
|
del(`/roster/${employeeId}/attendance/${id}`),
|
||||||
/** 培训记录 */
|
/** 培训记录 */
|
||||||
training: (employeeId: string, data: any) =>
|
training: (employeeId: string, data: Record<string, unknown>) =>
|
||||||
post(`/roster/${employeeId}/training`, data),
|
post(`/roster/${employeeId}/training`, data),
|
||||||
/** 删除培训记录 */
|
/** 删除培训记录 */
|
||||||
removeTraining: (employeeId: string, id: string) =>
|
removeTraining: (employeeId: string, id: string) =>
|
||||||
del(`/roster/${employeeId}/training/${id}`),
|
del(`/roster/${employeeId}/training/${id}`),
|
||||||
/** 绩效记录 */
|
/** 绩效记录 */
|
||||||
performance: (employeeId: string, data: any) =>
|
performance: (employeeId: string, data: Record<string, unknown>) =>
|
||||||
post(`/roster/${employeeId}/performance`, data),
|
post(`/roster/${employeeId}/performance`, data),
|
||||||
/** 删除绩效记录 */
|
/** 删除绩效记录 */
|
||||||
removePerformance: (employeeId: string, id: string) =>
|
removePerformance: (employeeId: string, id: string) =>
|
||||||
del(`/roster/${employeeId}/performance/${id}`),
|
del(`/roster/${employeeId}/performance/${id}`),
|
||||||
/** 违纪记录-创建 */
|
/** 违纪记录-创建 */
|
||||||
createDisciplinary: (employeeId: string, data: any) =>
|
createDisciplinary: (employeeId: string, data: Record<string, unknown>) =>
|
||||||
post(`/roster/${employeeId}/disciplinary`, data),
|
post(`/roster/${employeeId}/disciplinary`, data),
|
||||||
/** 删除违纪记录 */
|
/** 删除违纪记录 */
|
||||||
removeDisciplinary: (employeeId: string, id: string) =>
|
removeDisciplinary: (employeeId: string, id: string) =>
|
||||||
@@ -164,7 +164,7 @@ export const rosterApi = {
|
|||||||
|
|
||||||
export const attachmentApi = {
|
export const attachmentApi = {
|
||||||
/** 添加附件 */
|
/** 添加附件 */
|
||||||
add: (data: any) =>
|
add: (data: Record<string, unknown>) =>
|
||||||
post('/attachments', data),
|
post('/attachments', data),
|
||||||
/** 获取附件列表 */
|
/** 获取附件列表 */
|
||||||
list: (employeeId: string) =>
|
list: (employeeId: string) =>
|
||||||
@@ -265,25 +265,42 @@ export const attendanceApi = {
|
|||||||
confirm: (data: { employeeId: string; month: string }) =>
|
confirm: (data: { employeeId: string; month: string }) =>
|
||||||
post('/attendance/confirm', data).then(unwrap<any>()),
|
post('/attendance/confirm', data).then(unwrap<any>()),
|
||||||
/** 创建/更新班次 */
|
/** 创建/更新班次 */
|
||||||
saveShift: (data: any, editId?: string) =>
|
saveShift: (data: Record<string, unknown>, editId?: string) =>
|
||||||
editId ? put(`/attendance/shifts/${editId}`, data) : post('/attendance/shifts', data),
|
editId ? put(`/attendance/shifts/${editId}`, data) : post('/attendance/shifts', data),
|
||||||
/** 删除班次 */
|
/** 删除班次 */
|
||||||
removeShift: (id: string) =>
|
removeShift: (id: string) =>
|
||||||
del(`/attendance/shifts/${id}`),
|
del(`/attendance/shifts/${id}`),
|
||||||
/** 批量排班 */
|
/** 批量排班 */
|
||||||
batchAssign: (items: any[]) =>
|
batchAssign: (items: Record<string, unknown>[]) =>
|
||||||
post('/attendance/shift-assignments/batch', { items }),
|
post('/attendance/shift-assignments/batch', { items }),
|
||||||
/** 删除排班 */
|
/** 删除排班 */
|
||||||
removeAssignment: (id: string) =>
|
removeAssignment: (id: string) =>
|
||||||
del(`/attendance/shift-assignments/${id}`),
|
del(`/attendance/shift-assignments/${id}`),
|
||||||
/** 创建请假记录 */
|
/** 创建请假记录 */
|
||||||
createLeave: (data: any) =>
|
createLeave: (data: Record<string, unknown>) =>
|
||||||
post('/attendance/leaves', data),
|
post('/attendance/leaves', data),
|
||||||
/** 删除请假记录 */
|
/** 删除请假记录 */
|
||||||
removeLeave: (id: string) =>
|
removeLeave: (id: string) =>
|
||||||
del(`/attendance/leaves/${id}`),
|
del(`/attendance/leaves/${id}`),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 休假审批流 ==========
|
||||||
|
|
||||||
|
export const leaveApi = {
|
||||||
|
list: (params?: Record<string, unknown>) =>
|
||||||
|
get('/leaves', { params }).then(unwrap<any>()),
|
||||||
|
stats: (month?: string) =>
|
||||||
|
get('/leaves/stats', { params: { month } }).then(unwrap<any>()),
|
||||||
|
create: (data: Record<string, unknown>) =>
|
||||||
|
post('/leaves', data).then(unwrap<any>()),
|
||||||
|
approve: (id: string, action: string, remark?: string) =>
|
||||||
|
post(`/leaves/${id}/approve`, { action, remark }).then(unwrap<any>()),
|
||||||
|
cancel: (id: string) =>
|
||||||
|
post(`/leaves/${id}/cancel`).then(unwrap<any>()),
|
||||||
|
remove: (id: string) =>
|
||||||
|
del(`/leaves/${id}`),
|
||||||
|
}
|
||||||
|
|
||||||
// ========== AI 相关 ==========
|
// ========== AI 相关 ==========
|
||||||
|
|
||||||
export const aiApi = {
|
export const aiApi = {
|
||||||
@@ -294,19 +311,19 @@ export const aiApi = {
|
|||||||
conversation: (id: string) =>
|
conversation: (id: string) =>
|
||||||
get(`/ai/conversations/${id}`).then(unwrap<any>()),
|
get(`/ai/conversations/${id}`).then(unwrap<any>()),
|
||||||
/** 创建对话 */
|
/** 创建对话 */
|
||||||
createConversation: (data: any) =>
|
createConversation: (data: Record<string, unknown>) =>
|
||||||
post('/ai/conversations', data).then(unwrap<any>()),
|
post('/ai/conversations', data).then(unwrap<any>()),
|
||||||
/** 更新对话 */
|
/** 更新对话 */
|
||||||
updateConversation: (id: string, data: any) =>
|
updateConversation: (id: string, data: Record<string, unknown>) =>
|
||||||
put(`/ai/conversations/${id}`, data),
|
put(`/ai/conversations/${id}`, data),
|
||||||
/** 删除对话 */
|
/** 删除对话 */
|
||||||
removeConversation: (id: string) =>
|
removeConversation: (id: string) =>
|
||||||
del(`/ai/conversations/${id}`),
|
del(`/ai/conversations/${id}`),
|
||||||
/** AI 咨询 */
|
/** AI 咨询 */
|
||||||
consult: (data: any) =>
|
consult: (data: Record<string, unknown>) =>
|
||||||
post('/ai/consultation', data).then(unwrap<any>()),
|
post('/ai/consultation', data).then(unwrap<any>()),
|
||||||
/** AI 上下文问答 */
|
/** AI 上下文问答 */
|
||||||
contextAsk: (data: any) =>
|
contextAsk: (data: Record<string, unknown>) =>
|
||||||
post('/ai/context-ask', data).then(unwrap<any>()),
|
post('/ai/context-ask', data).then(unwrap<any>()),
|
||||||
/** 合同审查上传 */
|
/** 合同审查上传 */
|
||||||
reviewUpload: (formData: FormData) =>
|
reviewUpload: (formData: FormData) =>
|
||||||
@@ -315,19 +332,19 @@ export const aiApi = {
|
|||||||
review: (contractText: string) =>
|
review: (contractText: string) =>
|
||||||
post('/ai/review', { contractText }).then(unwrap<any>()),
|
post('/ai/review', { contractText }).then(unwrap<any>()),
|
||||||
/** 保存审查结果 */
|
/** 保存审查结果 */
|
||||||
reviewSave: (data: any) =>
|
reviewSave: (data: Record<string, unknown>) =>
|
||||||
post('/ai/review/save', data),
|
post('/ai/review/save', data),
|
||||||
/** 案例匹配 */
|
/** 案例匹配 */
|
||||||
matchCase: (scenario: string) =>
|
matchCase: (scenario: string) =>
|
||||||
post('/ai/match-case', { scenario }).then(unwrap<any>()),
|
post('/ai/match-case', { scenario }).then(unwrap<any>()),
|
||||||
/** 案例转待办 */
|
/** 案例转待办 */
|
||||||
caseToTodo: (data: any) =>
|
caseToTodo: (data: Record<string, unknown>) =>
|
||||||
post('/ai/case-to-todo', data),
|
post('/ai/case-to-todo', data),
|
||||||
/** RAG 知识库列表 */
|
/** RAG 知识库列表 */
|
||||||
ragList: () =>
|
ragList: () =>
|
||||||
get('/ai/rag/list').then(unwrap<any[]>()),
|
get('/ai/rag/list').then(unwrap<any[]>()),
|
||||||
/** RAG 添加知识 */
|
/** RAG 添加知识 */
|
||||||
ragAdd: (data: any) =>
|
ragAdd: (data: Record<string, unknown>) =>
|
||||||
post('/ai/rag/add', data),
|
post('/ai/rag/add', data),
|
||||||
/** RAG 删除知识 */
|
/** RAG 删除知识 */
|
||||||
ragRemove: (id: string) =>
|
ragRemove: (id: string) =>
|
||||||
@@ -347,10 +364,10 @@ export const payrollApi = {
|
|||||||
template: () =>
|
template: () =>
|
||||||
get('/payroll2/template').then(unwrap<any[]>()),
|
get('/payroll2/template').then(unwrap<any[]>()),
|
||||||
/** 创建模版项 */
|
/** 创建模版项 */
|
||||||
createTemplateItem: (data: any) =>
|
createTemplateItem: (data: Record<string, unknown>) =>
|
||||||
post('/payroll2/template', data),
|
post('/payroll2/template', data),
|
||||||
/** 更新模版项 */
|
/** 更新模版项 */
|
||||||
updateTemplateItem: (id: string, data: any) =>
|
updateTemplateItem: (id: string, data: Record<string, unknown>) =>
|
||||||
put(`/payroll2/template/${id}`, data),
|
put(`/payroll2/template/${id}`, data),
|
||||||
/** 删除模版项 */
|
/** 删除模版项 */
|
||||||
removeTemplateItem: (id: string) =>
|
removeTemplateItem: (id: string) =>
|
||||||
@@ -359,7 +376,7 @@ export const payrollApi = {
|
|||||||
batchCheck: (month: string) =>
|
batchCheck: (month: string) =>
|
||||||
get('/payroll2/batches/check', { params: { month } }).then(unwrap<any>()),
|
get('/payroll2/batches/check', { params: { month } }).then(unwrap<any>()),
|
||||||
/** 批次列表 */
|
/** 批次列表 */
|
||||||
batches: (params?: any) =>
|
batches: (params?: Record<string, unknown>) =>
|
||||||
get('/payroll2/batches', { params }).then(unwrap<any[]>()),
|
get('/payroll2/batches', { params }).then(unwrap<any[]>()),
|
||||||
/** 归档批次列表 */
|
/** 归档批次列表 */
|
||||||
archivedBatches: () =>
|
archivedBatches: () =>
|
||||||
@@ -368,7 +385,7 @@ export const payrollApi = {
|
|||||||
batchDetail: (id: string) =>
|
batchDetail: (id: string) =>
|
||||||
get(`/payroll2/batches/${id}`).then(unwrap<any>()),
|
get(`/payroll2/batches/${id}`).then(unwrap<any>()),
|
||||||
/** 创建批次 */
|
/** 创建批次 */
|
||||||
createBatch: (data: any) =>
|
createBatch: (data: Record<string, unknown>) =>
|
||||||
post('/payroll2/batches', data),
|
post('/payroll2/batches', data),
|
||||||
/** 重命名批次 */
|
/** 重命名批次 */
|
||||||
renameBatch: (batchId: string, name: string) =>
|
renameBatch: (batchId: string, name: string) =>
|
||||||
@@ -395,8 +412,11 @@ export const payrollApi = {
|
|||||||
removeBatchEmployee: (batchId: string, employeeId: string) =>
|
removeBatchEmployee: (batchId: string, employeeId: string) =>
|
||||||
del(`/payroll2/batches/${batchId}/employees/${employeeId}`),
|
del(`/payroll2/batches/${batchId}/employees/${employeeId}`),
|
||||||
/** 更新批次条目 */
|
/** 更新批次条目 */
|
||||||
updateBatchEntry: (batchId: string, employeeId: string, data: any) =>
|
updateBatchEntry: (batchId: string, employeeId: string, data: Record<string, unknown>) =>
|
||||||
put(`/payroll2/batches/${batchId}/entries/${employeeId}`, data),
|
put(`/payroll2/batches/${batchId}/entries/${employeeId}`, data),
|
||||||
|
/** 获取条目个税计算明细 */
|
||||||
|
taxDetail: (batchId: string, employeeId: string) =>
|
||||||
|
get(`/payroll2/batches/${batchId}/entries/${employeeId}/tax-detail`).then(unwrap<any>()),
|
||||||
/** 算薪前 AI 校验 */
|
/** 算薪前 AI 校验 */
|
||||||
preCheck: (batchId: string) =>
|
preCheck: (batchId: string) =>
|
||||||
get(`/payroll2/batches/${batchId}/pre-check`).then(unwrap<any>()),
|
get(`/payroll2/batches/${batchId}/pre-check`).then(unwrap<any>()),
|
||||||
@@ -407,13 +427,13 @@ export const payrollApi = {
|
|||||||
overtimeRecords: (params: { month?: string; employeeId?: string }) =>
|
overtimeRecords: (params: { month?: string; employeeId?: string }) =>
|
||||||
get('/payroll/overtime', { params }).then(unwrap<any[]>()),
|
get('/payroll/overtime', { params }).then(unwrap<any[]>()),
|
||||||
/** 保存加班费记录 */
|
/** 保存加班费记录 */
|
||||||
saveOvertime: (data: any) =>
|
saveOvertime: (data: Record<string, unknown>) =>
|
||||||
post('/payroll/overtime', data).then(unwrap<any>()),
|
post('/payroll/overtime', data).then(unwrap<any>()),
|
||||||
/** 更新加班费记录 */
|
/** 更新加班费记录 */
|
||||||
updateOvertime: (id: string, data: any) =>
|
updateOvertime: (id: string, data: Record<string, unknown>) =>
|
||||||
put(`/payroll/overtime/${id}`, data).then(unwrap<any>()),
|
put(`/payroll/overtime/${id}`, data).then(unwrap<any>()),
|
||||||
/** 批量导入加班工时 */
|
/** 批量导入加班工时 */
|
||||||
batchImportOvertime: (data: any[]) =>
|
batchImportOvertime: (data: Record<string, unknown>[]) =>
|
||||||
post('/payroll/overtime/batch', data),
|
post('/payroll/overtime/batch', data),
|
||||||
/** 导入加班费到批次 */
|
/** 导入加班费到批次 */
|
||||||
importOvertimeToBatch: (batchId: string) =>
|
importOvertimeToBatch: (batchId: string) =>
|
||||||
@@ -422,19 +442,19 @@ export const payrollApi = {
|
|||||||
overtimeConfig: () =>
|
overtimeConfig: () =>
|
||||||
get('/payroll/overtime/config').then(unwrap<any>()),
|
get('/payroll/overtime/config').then(unwrap<any>()),
|
||||||
/** 保存加班费配置 */
|
/** 保存加班费配置 */
|
||||||
saveOvertimeConfig: (data: any) =>
|
saveOvertimeConfig: (data: Record<string, unknown>) =>
|
||||||
post('/payroll/overtime/config', data),
|
post('/payroll/overtime/config', data),
|
||||||
/** 工资条列表 */
|
/** 工资条列表 */
|
||||||
payslips: (params: { month?: string; employeeId?: string }) =>
|
payslips: (params: { month?: string; employeeId?: string }) =>
|
||||||
get('/payroll/payslip', { params }).then(unwrap<any[]>()),
|
get('/payroll/payslip', { params }).then(unwrap<any[]>()),
|
||||||
/** 创建/更新工资条 */
|
/** 创建/更新工资条 */
|
||||||
savePayslip: (data: any) =>
|
savePayslip: (data: Record<string, unknown>) =>
|
||||||
post('/payroll/payslip', data).then(unwrap<any>()),
|
post('/payroll/payslip', data).then(unwrap<any>()),
|
||||||
/** 删除工资条 */
|
/** 删除工资条 */
|
||||||
removePayslip: (id: string) =>
|
removePayslip: (id: string) =>
|
||||||
del(`/payroll/payslip/${id}`),
|
del(`/payroll/payslip/${id}`),
|
||||||
/** 个税试算 */
|
/** 个税试算 */
|
||||||
taxPreview: (data: any) =>
|
taxPreview: (data: Record<string, unknown>) =>
|
||||||
post('/payroll/tax-preview', data).then(unwrap<any>()),
|
post('/payroll/tax-preview', data).then(unwrap<any>()),
|
||||||
/** 薪资汇总表 */
|
/** 薪资汇总表 */
|
||||||
batchSummary: (id: string) =>
|
batchSummary: (id: string) =>
|
||||||
@@ -470,10 +490,10 @@ export const socialInsuranceApi = {
|
|||||||
housingConfigVersions: (city?: string) =>
|
housingConfigVersions: (city?: string) =>
|
||||||
get('/social/housing-config/versions', { params: city ? { city } : {} }).then(unwrap<any[]>()),
|
get('/social/housing-config/versions', { params: city ? { city } : {} }).then(unwrap<any[]>()),
|
||||||
/** 创建社保配置版本 */
|
/** 创建社保配置版本 */
|
||||||
createConfigVersion: (data: any) =>
|
createConfigVersion: (data: Record<string, unknown>) =>
|
||||||
post('/social/config/versions', data),
|
post('/social/config/versions', data),
|
||||||
/** 创建公积金配置版本 */
|
/** 创建公积金配置版本 */
|
||||||
createHousingConfigVersion: (data: any) =>
|
createHousingConfigVersion: (data: Record<string, unknown>) =>
|
||||||
post('/social/housing-config/versions', data),
|
post('/social/housing-config/versions', data),
|
||||||
/** 社保计算 */
|
/** 社保计算 */
|
||||||
calculate: (base: number, city: string) =>
|
calculate: (base: number, city: string) =>
|
||||||
@@ -491,10 +511,10 @@ export const socialInsuranceApi = {
|
|||||||
housingAdjustPreview: (configId: string) =>
|
housingAdjustPreview: (configId: string) =>
|
||||||
get(`/social/housing-config/${configId}/adjust-preview`).then(unwrap<any>()),
|
get(`/social/housing-config/${configId}/adjust-preview`).then(unwrap<any>()),
|
||||||
/** 应用调整 */
|
/** 应用调整 */
|
||||||
applyAdjust: (configId: string, data: any) =>
|
applyAdjust: (configId: string, data: Record<string, unknown>) =>
|
||||||
post(`/social/config/${configId}/adjust-apply`, data),
|
post(`/social/config/${configId}/adjust-apply`, data),
|
||||||
/** 应用公积金调整 */
|
/** 应用公积金调整 */
|
||||||
applyHousingAdjust: (configId: string, data: any) =>
|
applyHousingAdjust: (configId: string, data: Record<string, unknown>) =>
|
||||||
post(`/social/housing-config/${configId}/adjust-apply`, data),
|
post(`/social/housing-config/${configId}/adjust-apply`, data),
|
||||||
/** 重置调整 */
|
/** 重置调整 */
|
||||||
resetAdjust: (configId: string, city: string) =>
|
resetAdjust: (configId: string, city: string) =>
|
||||||
@@ -509,19 +529,19 @@ export const socialInsuranceApi = {
|
|||||||
monthlyProcessStatus: (month: string) =>
|
monthlyProcessStatus: (month: string) =>
|
||||||
get('/social/monthly-process/status', { params: { month } }).then(unwrap<any>()),
|
get('/social/monthly-process/status', { params: { month } }).then(unwrap<any>()),
|
||||||
/** 完成月度办理 */
|
/** 完成月度办理 */
|
||||||
completeMonthlyProcess: (data: any) =>
|
completeMonthlyProcess: (data: Record<string, unknown>) =>
|
||||||
post('/social/monthly-process/complete', data),
|
post('/social/monthly-process/complete', data),
|
||||||
/** 专项附加扣除批量 */
|
/** 专项附加扣除批量 */
|
||||||
specialDeductionBatch: (month: string) =>
|
specialDeductionBatch: (month: string) =>
|
||||||
get('/social/special-deduction/batch', { params: { month } }).then(unwrap<any[]>()),
|
get('/social/special-deduction/batch', { params: { month } }).then(unwrap<any[]>()),
|
||||||
/** 保存专项附加扣除 */
|
/** 保存专项附加扣除 */
|
||||||
saveSpecialDeduction: (data: any) =>
|
saveSpecialDeduction: (data: Record<string, unknown>) =>
|
||||||
post('/social/special-deduction', data),
|
post('/social/special-deduction', data),
|
||||||
/** 活跃申报员工 */
|
/** 活跃申报员工 */
|
||||||
activeDeclaration: (month: string) =>
|
activeDeclaration: (month: string) =>
|
||||||
get('/social/active-declaration', { params: { month } }).then(unwrap<any>()),
|
get('/social/active-declaration', { params: { month } }).then(unwrap<any>()),
|
||||||
/** 社保/公积金记录更正 */
|
/** 社保/公积金记录更正 */
|
||||||
correctRecord: (type: 'social' | 'housing', id: string, data: any) =>
|
correctRecord: (type: 'social' | 'housing', id: string, data: Record<string, unknown>) =>
|
||||||
put(`/social/records/${type}/${id}/correct`, data).then(unwrap<any>()),
|
put(`/social/records/${type}/${id}/correct`, data).then(unwrap<any>()),
|
||||||
/** 社保月度变动 */
|
/** 社保月度变动 */
|
||||||
monthlyChanges: (month: string) =>
|
monthlyChanges: (month: string) =>
|
||||||
@@ -544,7 +564,7 @@ export const commercialInsuranceApi = {
|
|||||||
enrollments: (planId: string) =>
|
enrollments: (planId: string) =>
|
||||||
get(`/commercial-insurance/plans/${planId}/enrollments`).then(unwrap<any[]>()),
|
get(`/commercial-insurance/plans/${planId}/enrollments`).then(unwrap<any[]>()),
|
||||||
/** 创建/更新方案 */
|
/** 创建/更新方案 */
|
||||||
savePlan: (data: any, editId?: string) =>
|
savePlan: (data: Record<string, unknown>, editId?: string) =>
|
||||||
editId ? put(`/commercial-insurance/plans/${editId}`, data) : post('/commercial-insurance/plans', data),
|
editId ? put(`/commercial-insurance/plans/${editId}`, data) : post('/commercial-insurance/plans', data),
|
||||||
/** 删除方案 */
|
/** 删除方案 */
|
||||||
removePlan: (id: string) =>
|
removePlan: (id: string) =>
|
||||||
@@ -555,16 +575,16 @@ export const commercialInsuranceApi = {
|
|||||||
|
|
||||||
export const terminationApi = {
|
export const terminationApi = {
|
||||||
/** 创建离职 */
|
/** 创建离职 */
|
||||||
create: (data: any) =>
|
create: (data: Record<string, unknown>) =>
|
||||||
post('/termination', data),
|
post('/termination', data),
|
||||||
/** 创建草稿 */
|
/** 创建草稿 */
|
||||||
createDraft: (data: any) =>
|
createDraft: (data: Record<string, unknown>) =>
|
||||||
post('/termination/draft', data),
|
post('/termination/draft', data),
|
||||||
/** 更新草稿 */
|
/** 更新草稿 */
|
||||||
updateDraft: (draftId: string, data: any) =>
|
updateDraft: (draftId: string, data: Record<string, unknown>) =>
|
||||||
put(`/termination/draft/${draftId}`, data),
|
put(`/termination/draft/${draftId}`, data),
|
||||||
/** 草稿列表 */
|
/** 草稿列表 */
|
||||||
drafts: (params: any) =>
|
drafts: (params: Record<string, unknown>) =>
|
||||||
get('/termination/drafts', { params }).then(unwrap<any>()),
|
get('/termination/drafts', { params }).then(unwrap<any>()),
|
||||||
/** 草稿详情 */
|
/** 草稿详情 */
|
||||||
detail: (draftId: string) =>
|
detail: (draftId: string) =>
|
||||||
@@ -594,7 +614,7 @@ export const terminationApi = {
|
|||||||
assess: (employeeId: string, reason: string) =>
|
assess: (employeeId: string, reason: string) =>
|
||||||
get(`/termination/assess/${employeeId}`, { params: { reason } }).then(unwrap<any>()),
|
get(`/termination/assess/${employeeId}`, { params: { reason } }).then(unwrap<any>()),
|
||||||
/** 批量预览 */
|
/** 批量预览 */
|
||||||
batchPreview: (items: any[]) =>
|
batchPreview: (items: Record<string, unknown>[]) =>
|
||||||
post('/termination/batch/preview', { items }),
|
post('/termination/batch/preview', { items }),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,10 +628,10 @@ export const policiesApi = {
|
|||||||
detail: (id: string) =>
|
detail: (id: string) =>
|
||||||
get(`/policies/${id}`).then(unwrap<any>()),
|
get(`/policies/${id}`).then(unwrap<any>()),
|
||||||
/** 创建制度 */
|
/** 创建制度 */
|
||||||
create: (data: any) =>
|
create: (data: Record<string, unknown>) =>
|
||||||
post('/policies', data),
|
post('/policies', data),
|
||||||
/** 更新制度 */
|
/** 更新制度 */
|
||||||
update: (id: string, data: any) =>
|
update: (id: string, data: Record<string, unknown>) =>
|
||||||
put(`/policies/${id}`, data),
|
put(`/policies/${id}`, data),
|
||||||
/** 推进民主程序 */
|
/** 推进民主程序 */
|
||||||
advanceStep: (id: string, step: number, note?: string) =>
|
advanceStep: (id: string, step: number, note?: string) =>
|
||||||
@@ -653,7 +673,7 @@ export const calendarApi = {
|
|||||||
events: (month: string) =>
|
events: (month: string) =>
|
||||||
get(`/calendar?month=${month}`).then(unwrap<any[]>()),
|
get(`/calendar?month=${month}`).then(unwrap<any[]>()),
|
||||||
/** 创建事件 */
|
/** 创建事件 */
|
||||||
createEvent: (data: any) =>
|
createEvent: (data: Record<string, unknown>) =>
|
||||||
post('/calendar', data),
|
post('/calendar', data),
|
||||||
/** 删除事件 */
|
/** 删除事件 */
|
||||||
removeEvent: (id: string) =>
|
removeEvent: (id: string) =>
|
||||||
@@ -667,7 +687,7 @@ export const companyFilesApi = {
|
|||||||
list: (params?: { fileType?: string }) =>
|
list: (params?: { fileType?: string }) =>
|
||||||
get('/company-files', { params }).then(unwrap<any[]>()),
|
get('/company-files', { params }).then(unwrap<any[]>()),
|
||||||
/** 添加文件 */
|
/** 添加文件 */
|
||||||
add: (data: any) =>
|
add: (data: Record<string, unknown>) =>
|
||||||
post('/company-files', data),
|
post('/company-files', data),
|
||||||
/** 删除文件 */
|
/** 删除文件 */
|
||||||
remove: (id: string) =>
|
remove: (id: string) =>
|
||||||
@@ -684,7 +704,7 @@ export const notificationsApi = {
|
|||||||
settings: () =>
|
settings: () =>
|
||||||
get('/notifications/settings').then(unwrap<any>()),
|
get('/notifications/settings').then(unwrap<any>()),
|
||||||
/** 更新通知设置 */
|
/** 更新通知设置 */
|
||||||
updateSettings: (data: any) =>
|
updateSettings: (data: Record<string, unknown>) =>
|
||||||
put('/notifications/settings', data),
|
put('/notifications/settings', data),
|
||||||
/** 检查合同到期 */
|
/** 检查合同到期 */
|
||||||
checkContracts: () =>
|
checkContracts: () =>
|
||||||
@@ -701,16 +721,16 @@ export const settingsApi = {
|
|||||||
org: () =>
|
org: () =>
|
||||||
get('/settings/org').then(unwrap<any>()),
|
get('/settings/org').then(unwrap<any>()),
|
||||||
/** 更新组织设置 */
|
/** 更新组织设置 */
|
||||||
updateOrg: (data: any) =>
|
updateOrg: (data: Record<string, unknown>) =>
|
||||||
put('/settings/org', data),
|
put('/settings/org', data),
|
||||||
/** 用户列表 */
|
/** 用户列表 */
|
||||||
users: () =>
|
users: () =>
|
||||||
get('/settings/users').then(unwrap<any>()),
|
get('/settings/users').then(unwrap<any>()),
|
||||||
/** 添加用户 */
|
/** 添加用户 */
|
||||||
addUser: (data: any) =>
|
addUser: (data: Record<string, unknown>) =>
|
||||||
post('/settings/users', data),
|
post('/settings/users', data),
|
||||||
/** 更新用户 */
|
/** 更新用户 */
|
||||||
updateUser: (id: string, data: any) =>
|
updateUser: (id: string, data: Record<string, unknown>) =>
|
||||||
put(`/settings/users/${id}`, data),
|
put(`/settings/users/${id}`, data),
|
||||||
/** 切换用户禁用状态 */
|
/** 切换用户禁用状态 */
|
||||||
toggleDisable: (id: string) =>
|
toggleDisable: (id: string) =>
|
||||||
@@ -739,7 +759,7 @@ export const templatesApi = {
|
|||||||
detail: (id: string) =>
|
detail: (id: string) =>
|
||||||
get(`/templates/${id}`).then(unwrap<any>()),
|
get(`/templates/${id}`).then(unwrap<any>()),
|
||||||
/** 渲染模板 */
|
/** 渲染模板 */
|
||||||
render: (id: string, variables: any) =>
|
render: (id: string, variables: Record<string, unknown>) =>
|
||||||
post(`/templates/${id}/render`, { variables }).then(unwrap<any>()),
|
post(`/templates/${id}/render`, { variables }).then(unwrap<any>()),
|
||||||
/** 企业模板列表 */
|
/** 企业模板列表 */
|
||||||
enterpriseList: (params: { page?: number; pageSize?: number; category?: string }) =>
|
enterpriseList: (params: { page?: number; pageSize?: number; category?: string }) =>
|
||||||
@@ -748,13 +768,13 @@ export const templatesApi = {
|
|||||||
enterpriseDetail: (id: string) =>
|
enterpriseDetail: (id: string) =>
|
||||||
get(`/enterprise-templates/${id}`).then(unwrap<any>()),
|
get(`/enterprise-templates/${id}`).then(unwrap<any>()),
|
||||||
/** 创建/更新企业模板 */
|
/** 创建/更新企业模板 */
|
||||||
saveEnterprise: (data: any, editId?: string) =>
|
saveEnterprise: (data: Record<string, unknown>, editId?: string) =>
|
||||||
editId ? put(`/enterprise-templates/${editId}`, data) : post('/enterprise-templates', data),
|
editId ? put(`/enterprise-templates/${editId}`, data) : post('/enterprise-templates', data),
|
||||||
/** 删除企业模板 */
|
/** 删除企业模板 */
|
||||||
removeEnterprise: (id: string) =>
|
removeEnterprise: (id: string) =>
|
||||||
del(`/enterprise-templates/${id}`),
|
del(`/enterprise-templates/${id}`),
|
||||||
/** 渲染企业模板 */
|
/** 渲染企业模板 */
|
||||||
renderEnterprise: (id: string, variables: any) =>
|
renderEnterprise: (id: string, variables: Record<string, unknown>) =>
|
||||||
post(`/enterprise-templates/${id}/render`, { variables }).then(unwrap<any>()),
|
post(`/enterprise-templates/${id}/render`, { variables }).then(unwrap<any>()),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,7 +788,7 @@ export const workProcessApi = {
|
|||||||
detail: (id: string) =>
|
detail: (id: string) =>
|
||||||
get(`/work-processes/${id}`).then(unwrap<any>()),
|
get(`/work-processes/${id}`).then(unwrap<any>()),
|
||||||
/** 创建 */
|
/** 创建 */
|
||||||
create: (data: any) =>
|
create: (data: Record<string, unknown>) =>
|
||||||
post('/work-processes', data).then(unwrap<any>()),
|
post('/work-processes', data).then(unwrap<any>()),
|
||||||
/** 提交 */
|
/** 提交 */
|
||||||
submit: (id: string) =>
|
submit: (id: string) =>
|
||||||
@@ -794,10 +814,10 @@ export const specialStatusApi = {
|
|||||||
stats: () =>
|
stats: () =>
|
||||||
get('/special-statuses/stats/overview').then(unwrap<any>()),
|
get('/special-statuses/stats/overview').then(unwrap<any>()),
|
||||||
/** 创建 */
|
/** 创建 */
|
||||||
create: (data: any) =>
|
create: (data: Record<string, unknown>) =>
|
||||||
post('/special-statuses', data),
|
post('/special-statuses', data),
|
||||||
/** 更新 */
|
/** 更新 */
|
||||||
update: (id: string, data: any) =>
|
update: (id: string, data: Record<string, unknown>) =>
|
||||||
put(`/special-statuses/${id}`, data),
|
put(`/special-statuses/${id}`, data),
|
||||||
/** 删除 */
|
/** 删除 */
|
||||||
remove: (id: string) =>
|
remove: (id: string) =>
|
||||||
@@ -816,7 +836,7 @@ export const searchApi = {
|
|||||||
|
|
||||||
export const surveyApi = {
|
export const surveyApi = {
|
||||||
/** 提交问卷 */
|
/** 提交问卷 */
|
||||||
submit: (items: any[]) =>
|
submit: (items: Record<string, unknown>[]) =>
|
||||||
post('/survey/submit', { items }),
|
post('/survey/submit', { items }),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -833,13 +853,13 @@ export const platformApi = {
|
|||||||
orgDetail: (id: string) =>
|
orgDetail: (id: string) =>
|
||||||
get(`/platform/orgs/${id}`).then(unwrap<any>()),
|
get(`/platform/orgs/${id}`).then(unwrap<any>()),
|
||||||
/** 创建组织 */
|
/** 创建组织 */
|
||||||
createOrg: (data: any) =>
|
createOrg: (data: Record<string, unknown>) =>
|
||||||
post('/platform/orgs', data),
|
post('/platform/orgs', data),
|
||||||
/** 更新组织 */
|
/** 更新组织 */
|
||||||
updateOrg: (id: string, data: any) =>
|
updateOrg: (id: string, data: Record<string, unknown>) =>
|
||||||
put(`/platform/orgs/${id}`, data),
|
put(`/platform/orgs/${id}`, data),
|
||||||
/** 更新组织管理员 */
|
/** 更新组织管理员 */
|
||||||
updateOrgAdmin: (id: string, data: any) =>
|
updateOrgAdmin: (id: string, data: Record<string, unknown>) =>
|
||||||
put(`/platform/orgs/${id}/admin`, data),
|
put(`/platform/orgs/${id}/admin`, data),
|
||||||
/** 删除组织 */
|
/** 删除组织 */
|
||||||
removeOrg: (id: string) =>
|
removeOrg: (id: string) =>
|
||||||
@@ -914,7 +934,7 @@ export const portalApi = {
|
|||||||
onboardingInfo: (token: string) =>
|
onboardingInfo: (token: string) =>
|
||||||
portalGet(`/onboarding/${token}`).then(unwrap<any>()),
|
portalGet(`/onboarding/${token}`).then(unwrap<any>()),
|
||||||
/** 入职提交 */
|
/** 入职提交 */
|
||||||
onboardingSubmit: (data: any) =>
|
onboardingSubmit: (data: Record<string, unknown>) =>
|
||||||
portalPost('/onboarding', data),
|
portalPost('/onboarding', data),
|
||||||
/** 入职上传文件 */
|
/** 入职上传文件 */
|
||||||
onboardingUpload: (token: string, formData: FormData) =>
|
onboardingUpload: (token: string, formData: FormData) =>
|
||||||
@@ -935,9 +955,18 @@ export const portalApi = {
|
|||||||
resignationStatus: () =>
|
resignationStatus: () =>
|
||||||
portalGet('/resignation/status').then(unwrap<any[]>()),
|
portalGet('/resignation/status').then(unwrap<any[]>()),
|
||||||
/** 提交离职申请 */
|
/** 提交离职申请 */
|
||||||
resignationSubmit: (data: any) =>
|
resignationSubmit: (data: Record<string, unknown>) =>
|
||||||
portalPost('/resignation/submit', data).then(unwrap<any>()),
|
portalPost('/resignation/submit', data).then(unwrap<any>()),
|
||||||
/** 撤回离职申请 */
|
/** 撤回离职申请 */
|
||||||
resignationWithdraw: (id: string) =>
|
resignationWithdraw: (id: string) =>
|
||||||
portalPost(`/resignation/${id}/withdraw`).then(unwrap<any>()),
|
portalPost(`/resignation/${id}/withdraw`).then(unwrap<any>()),
|
||||||
|
/** 我的休假申请列表 */
|
||||||
|
myLeaves: () =>
|
||||||
|
portalGet('/leaves').then(unwrap<any[]>()),
|
||||||
|
/** 提交休假申请 */
|
||||||
|
submitLeave: (data: Record<string, unknown>) =>
|
||||||
|
portalPost('/leaves', data).then(unwrap<any>()),
|
||||||
|
/** 撤回休假申请 */
|
||||||
|
cancelLeave: (id: string) =>
|
||||||
|
portalPost(`/leaves/${id}/cancel`).then(unwrap<any>()),
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-1
@@ -17,6 +17,12 @@ api.interceptors.request.use((config) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
let isRefreshing = false
|
let isRefreshing = false
|
||||||
|
let refreshQueue: Array<(token: string) => void> = []
|
||||||
|
|
||||||
|
function onTokenRefreshed(token: string) {
|
||||||
|
refreshQueue.forEach((cb) => cb(token))
|
||||||
|
refreshQueue = []
|
||||||
|
}
|
||||||
|
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => response.data,
|
(response) => response.data,
|
||||||
@@ -24,7 +30,16 @@ api.interceptors.response.use(
|
|||||||
const originalRequest = error.config
|
const originalRequest = error.config
|
||||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
originalRequest._retry = true
|
originalRequest._retry = true
|
||||||
if (isRefreshing) return Promise.reject(error)
|
|
||||||
|
if (isRefreshing) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
refreshQueue.push((newToken: string) => {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||||
|
resolve(api(originalRequest))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
try {
|
try {
|
||||||
const refreshToken = useAuthStore.getState().refreshToken
|
const refreshToken = useAuthStore.getState().refreshToken
|
||||||
@@ -32,9 +47,11 @@ api.interceptors.response.use(
|
|||||||
const res = await axios.post(`${API_BASE}/auth/refresh`, { refreshToken })
|
const res = await axios.post(`${API_BASE}/auth/refresh`, { refreshToken })
|
||||||
const newToken = res.data.data.accessToken
|
const newToken = res.data.data.accessToken
|
||||||
useAuthStore.getState().updateToken(newToken)
|
useAuthStore.getState().updateToken(newToken)
|
||||||
|
onTokenRefreshed(newToken)
|
||||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||||
return api(originalRequest)
|
return api(originalRequest)
|
||||||
} catch {
|
} catch {
|
||||||
|
refreshQueue = []
|
||||||
useAuthStore.getState().logout()
|
useAuthStore.getState().logout()
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import ReactDOM from 'react-dom/client'
|
|||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import ErrorBoundary from './components/ErrorBoundary'
|
import ErrorBoundary from './components/ui/ErrorBoundary'
|
||||||
import { ConfirmProvider } from './hooks/useConfirm'
|
import { ConfirmProvider } from './hooks/useConfirm'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
|
|||||||
+11
-1993
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import { Input, Label, Select } from '../components/ui/Input'
|
|||||||
import Modal from '../components/ui/Modal'
|
import Modal from '../components/ui/Modal'
|
||||||
import EmptyState from '../components/ui/EmptyState'
|
import EmptyState from '../components/ui/EmptyState'
|
||||||
import { InlineAlert } from '../components/ui/InlineAlert'
|
import { InlineAlert } from '../components/ui/InlineAlert'
|
||||||
|
import PageGuide from '../components/ui/PageGuide'
|
||||||
import { useConfirm } from '../hooks/useConfirm'
|
import { useConfirm } from '../hooks/useConfirm'
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; icon: typeof CheckCircle }> = {
|
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; icon: typeof CheckCircle }> = {
|
||||||
@@ -505,6 +506,7 @@ function ConfirmTab() {
|
|||||||
// ========== 班次管理 Tab ==========
|
// ========== 班次管理 Tab ==========
|
||||||
function ShiftsTab() {
|
function ShiftsTab() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const confirm = useConfirm()
|
||||||
const [showAdd, setShowAdd] = useState(false)
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
const [editShift, setEditShift] = useState<any>(null)
|
const [editShift, setEditShift] = useState<any>(null)
|
||||||
const [form, setForm] = useState({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' })
|
const [form, setForm] = useState({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' })
|
||||||
@@ -543,6 +545,9 @@ function ShiftsTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<PageGuide>
|
||||||
|
班次管理用于定义考勤班次(如早班、晚班、全天班)。设置班次名称、上下班时间、弹性时间等。排班时引用此处定义的班次。
|
||||||
|
</PageGuide>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button onClick={() => { setEditShift(null); setForm({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' }); setShowAdd(true) }}>
|
<Button onClick={() => { setEditShift(null); setForm({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' }); setShowAdd(true) }}>
|
||||||
<Plus className="w-4 h-4 mr-1" />新增班次
|
<Plus className="w-4 h-4 mr-1" />新增班次
|
||||||
@@ -564,7 +569,7 @@ function ShiftsTab() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button className="text-xs text-gray-400 hover:text-primary px-1" onClick={() => { setEditShift(s); setForm(s); setShowAdd(true) }}>编辑</button>
|
<button className="text-xs text-gray-400 hover:text-primary px-1" onClick={() => { setEditShift(s); setForm(s); setShowAdd(true) }}>编辑</button>
|
||||||
<button className="text-xs text-gray-400 hover:text-red-500 px-1" onClick={() => { if (confirm('确认删除?')) deleteMutation.mutate(s.id) }}>删除</button>
|
<button className="text-xs text-gray-400 hover:text-red-500 px-1" onClick={async () => { if (await confirm({ title: '删除班次', message: '确认删除?' })) deleteMutation.mutate(s.id) }}>删除</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-xs text-gray-500 space-y-0.5">
|
<div className="mt-2 text-xs text-gray-500 space-y-0.5">
|
||||||
@@ -685,6 +690,9 @@ function ScheduleTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<PageGuide>
|
||||||
|
排班用于按日期为员工分配班次。选择日期后可查看当日排班情况,点击「排班」按钮为员工分配班次。支持批量排班和复制排班。
|
||||||
|
</PageGuide>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
@@ -809,6 +817,9 @@ function DailyTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<PageGuide>
|
||||||
|
每日出勤记录展示当日所有员工的打卡情况,包括上班/下班时间、迟到/早退/缺卡状态。可手动补卡或修正异常记录。
|
||||||
|
</PageGuide>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
@@ -890,6 +901,9 @@ function MonthlyTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<PageGuide>
|
||||||
|
月度报表汇总当月各员工的出勤天数、迟到次数、加班时长等统计数据。可导出月度考勤表用于发薪参考。
|
||||||
|
</PageGuide>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<input
|
<input
|
||||||
type="month"
|
type="month"
|
||||||
@@ -954,6 +968,7 @@ function MonthlyTab() {
|
|||||||
// ========== 休假记录 Tab ==========
|
// ========== 休假记录 Tab ==========
|
||||||
function LeavesTab() {
|
function LeavesTab() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const confirm = useConfirm()
|
||||||
const [showAdd, setShowAdd] = useState(false)
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
const [form, setForm] = useState({ employeeId: '', leaveType: 'PERSONAL', startDate: '', endDate: '', days: 1, reason: '', remark: '' })
|
const [form, setForm] = useState({ employeeId: '', leaveType: 'PERSONAL', startDate: '', endDate: '', days: 1, reason: '', remark: '' })
|
||||||
|
|
||||||
@@ -994,6 +1009,9 @@ function LeavesTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<PageGuide>
|
||||||
|
休假记录管理员工的请假信息,包括事假、病假、年假等。可手动录入休假记录,系统自动计算天数并关联考勤数据。
|
||||||
|
</PageGuide>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button onClick={() => setShowAdd(true)}>
|
<Button onClick={() => setShowAdd(true)}>
|
||||||
<Plus className="w-4 h-4 mr-1" />新增休假记录
|
<Plus className="w-4 h-4 mr-1" />新增休假记录
|
||||||
@@ -1025,7 +1043,7 @@ function LeavesTab() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="text-xs text-gray-400 hover:text-red-500 flex-shrink-0" onClick={() => { if (confirm('确认删除?')) deleteMutation.mutate(lv.id) }}>
|
<button className="text-xs text-gray-400 hover:text-red-500 flex-shrink-0" onClick={async () => { if (await confirm({ title: '删除休假记录', message: '确认删除?' })) deleteMutation.mutate(lv.id) }}>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { ScrollText, Search, Filter } from 'lucide-react'
|
import { ScrollText } from 'lucide-react'
|
||||||
import { auditApi } from '../lib/api-services'
|
import { auditApi } from '../lib/api-services'
|
||||||
import Card from '../components/ui/Card'
|
import Card from '../components/ui/Card'
|
||||||
import Button from '../components/ui/Button'
|
import Button from '../components/ui/Button'
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useMemo } from 'react'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { CalendarDays, Plus, Trash2, ChevronLeft, ChevronRight, X, MapPin, User } from 'lucide-react'
|
import { CalendarDays, Plus, Trash2, ChevronLeft, ChevronRight, X } from 'lucide-react'
|
||||||
import { dashboardApi, calendarApi } from '../lib/api-services'
|
import { dashboardApi, calendarApi } from '../lib/api-services'
|
||||||
import Card from '../components/ui/Card'
|
import Card from '../components/ui/Card'
|
||||||
import Button from '../components/ui/Button'
|
import Button from '../components/ui/Button'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Plus, Search, Paperclip, Trash2, X, FileText, Download, Eye } from 'lucide-react'
|
import { Plus, Search, Paperclip, Trash2, X, FileText, Download } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { rosterApi, employeeApi, attachmentApi } from '../lib/api-services'
|
import { rosterApi, employeeApi, attachmentApi } from '../lib/api-services'
|
||||||
import Card from '../components/ui/Card'
|
import Card from '../components/ui/Card'
|
||||||
|
|||||||
+447
-235
@@ -3,7 +3,7 @@ import { toast } from 'sonner'
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, RadialBarChart, RadialBar, PolarAngleAxis } from 'recharts'
|
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, RadialBarChart, RadialBar, PolarAngleAxis } from 'recharts'
|
||||||
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert, UserPlus, AlertCircle, Download, ChevronRight, TrendingDown, ShieldCheck, Lightbulb, BookOpen, Sparkles, Repeat, XCircle, Loader2 } 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, ShieldCheck, Lightbulb, BookOpen, Sparkles, Repeat, XCircle, Settings } from 'lucide-react'
|
||||||
import { dashboardApi, rosterApi, workProcessApi } from '../lib/api-services'
|
import { dashboardApi, rosterApi, workProcessApi } from '../lib/api-services'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
import Card from '../components/ui/Card'
|
import Card from '../components/ui/Card'
|
||||||
@@ -12,8 +12,8 @@ import EmptyState from '../components/ui/EmptyState'
|
|||||||
import Pagination from '../components/ui/Pagination'
|
import Pagination from '../components/ui/Pagination'
|
||||||
import type { DashboardData } from '../types'
|
import type { DashboardData } from '../types'
|
||||||
import TurnoverStats from './dashboard/TurnoverStats'
|
import TurnoverStats from './dashboard/TurnoverStats'
|
||||||
|
import PageGuide from '../components/ui/PageGuide'
|
||||||
import PerformanceStats from './dashboard/PerformanceStats'
|
import PerformanceStats from './dashboard/PerformanceStats'
|
||||||
import { TaskCenter } from './dashboard/TaskCenter'
|
|
||||||
|
|
||||||
function fmt(n: number) {
|
function fmt(n: number) {
|
||||||
return `¥${(n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
return `¥${(n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||||
@@ -42,11 +42,73 @@ export default function Dashboard() {
|
|||||||
const [todoPage, setTodoPage] = useState(1)
|
const [todoPage, setTodoPage] = useState(1)
|
||||||
const [todoPageSize, setTodoPageSize] = useState(10)
|
const [todoPageSize, setTodoPageSize] = useState(10)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [activeTab, setActiveTab] = useState<'overview' | 'payroll' | 'risk' | 'task'>('overview')
|
const [activeTab, setActiveTab] = useState<'overview' | 'risk' | 'task' | 'cost' | 'workforce'>('overview')
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
const [drillDownType, setDrillDownType] = useState<string | null>(null)
|
const [drillDownType, setDrillDownType] = useState<string | null>(null)
|
||||||
const [showExpiringModal, setShowExpiringModal] = useState(false)
|
const [showExpiringModal, setShowExpiringModal] = useState(false)
|
||||||
const [dismissedExpiring, setDismissedExpiring] = useState(false)
|
const [dismissedExpiring, setDismissedExpiring] = useState(false)
|
||||||
|
const [showTabSettings, setShowTabSettings] = useState(false)
|
||||||
|
const [tabVisibility, setTabVisibility] = useState<Record<string, boolean>>(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('dashboard-tab-visibility')
|
||||||
|
if (saved) return JSON.parse(saved)
|
||||||
|
} catch {}
|
||||||
|
return { overview: true, risk: true, task: true, cost: true, workforce: true }
|
||||||
|
})
|
||||||
|
const toggleTabVisibility = (key: string) => {
|
||||||
|
setTabVisibility(prev => {
|
||||||
|
const next = { ...prev, [key]: !prev[key] }
|
||||||
|
localStorage.setItem('dashboard-tab-visibility', JSON.stringify(next))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const [settingsTab, setSettingsTab] = useState<string>('overview')
|
||||||
|
const [sectionVisibility, setSectionVisibility] = useState<Record<string, boolean>>(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('dashboard-section-visibility')
|
||||||
|
if (saved) return JSON.parse(saved)
|
||||||
|
} catch {}
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
const toggleSectionVisibility = (key: string) => {
|
||||||
|
setSectionVisibility(prev => {
|
||||||
|
const next = { ...prev, [key]: !prev[key] }
|
||||||
|
localStorage.setItem('dashboard-section-visibility', JSON.stringify(next))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const isSectionVisible = (key: string) => sectionVisibility[key] !== false
|
||||||
|
|
||||||
|
const tabSections: Record<string, { key: string; label: string }[]> = {
|
||||||
|
overview: [
|
||||||
|
{ key: 'overview_stats', label: '统计卡片' },
|
||||||
|
{ key: 'overview_compliance', label: '合规评分' },
|
||||||
|
{ key: 'overview_ai', label: 'AI 建议' },
|
||||||
|
{ key: 'overview_expiring', label: '合同到期预警' },
|
||||||
|
{ key: 'overview_nav', label: '快捷导航' },
|
||||||
|
],
|
||||||
|
risk: [
|
||||||
|
{ key: 'risk_distribution', label: '风险分布饼图' },
|
||||||
|
{ key: 'risk_todos', label: '待办列表' },
|
||||||
|
{ key: 'risk_resolved', label: '已办事项' },
|
||||||
|
],
|
||||||
|
task: [
|
||||||
|
{ key: 'task_todos', label: '待办列表' },
|
||||||
|
{ key: 'task_resolved', label: '已办事项' },
|
||||||
|
],
|
||||||
|
cost: [
|
||||||
|
{ key: 'cost_metrics', label: '成本指标卡片' },
|
||||||
|
{ key: 'cost_overview', label: '月度/年度成本' },
|
||||||
|
{ key: 'cost_analysis', label: '成本分析' },
|
||||||
|
{ key: 'cost_payroll', label: '薪税明细' },
|
||||||
|
],
|
||||||
|
workforce: [
|
||||||
|
{ key: 'workforce_activities', label: '本月工作动态' },
|
||||||
|
{ key: 'workforce_turnover', label: '入离职/绩效统计' },
|
||||||
|
{ key: 'workforce_distribution', label: '员工分布' },
|
||||||
|
],
|
||||||
|
}
|
||||||
const { data, isLoading, refetch, isFetching } = useQuery<DashboardData>({
|
const { data, isLoading, refetch, isFetching } = useQuery<DashboardData>({
|
||||||
queryKey: ['dashboard'],
|
queryKey: ['dashboard'],
|
||||||
queryFn: () => dashboardApi.data(),
|
queryFn: () => dashboardApi.data(),
|
||||||
@@ -203,6 +265,8 @@ export default function Dashboard() {
|
|||||||
{ key: 'overview' as const, label: '概览', icon: LayoutDashboard, badge: data.stats.todoCount },
|
{ key: 'overview' as const, label: '概览', icon: LayoutDashboard, badge: data.stats.todoCount },
|
||||||
{ key: 'risk' as const, label: '风险提醒', icon: AlertTriangle, badge: riskTodos.length },
|
{ key: 'risk' as const, label: '风险提醒', icon: AlertTriangle, badge: riskTodos.length },
|
||||||
{ key: 'task' as const, label: '月度任务', icon: ListTodo, badge: taskTodos.length },
|
{ key: 'task' as const, label: '月度任务', icon: ListTodo, badge: taskTodos.length },
|
||||||
|
{ key: 'cost' as const, label: '人力成本', icon: Wallet, badge: 0 },
|
||||||
|
{ key: 'workforce' as const, label: '人员分析', icon: Users, badge: 0 },
|
||||||
]
|
]
|
||||||
|
|
||||||
const priorityConfig: Record<string, { label: string; color: string; bg: string }> = {
|
const priorityConfig: Record<string, { label: string; color: string; bg: string }> = {
|
||||||
@@ -222,15 +286,77 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-gray-500">{data.greeting} · {payroll?.month} 月度总览</p>
|
<p className="mt-1 text-sm text-gray-500">{data.greeting} · {payroll?.month} 月度总览</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="secondary" size="sm" onClick={() => refetch()} disabled={isFetching} className={activeTab === 'risk' || activeTab === 'task' ? 'opacity-50 pointer-events-none' : ''}>
|
<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' : ''}`} />
|
<RefreshCw className={`w-4 h-4 mr-1 ${isFetching ? 'animate-spin' : ''}`} />
|
||||||
{isFetching ? '刷新中...' : activeTab === 'payroll' ? '刷新薪税' : '刷新概览'}
|
{isFetching ? '刷新中...' : '刷新'}
|
||||||
</Button>
|
</Button>
|
||||||
|
<div className="relative">
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => setShowTabSettings(!showTabSettings)}>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
{showTabSettings && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setShowTabSettings(false)} />
|
||||||
|
<div className="absolute right-0 top-full mt-1 z-50 bg-white rounded-lg shadow-lg border p-3 w-[240px] max-h-[80vh] overflow-y-auto">
|
||||||
|
<div className="text-xs font-medium text-gray-500 mb-2">工作台设置</div>
|
||||||
|
{tabs.map(tab => {
|
||||||
|
const TabIcon = tab.icon
|
||||||
|
const visible = tabVisibility[tab.key] !== false
|
||||||
|
const sections = tabSections[tab.key] || []
|
||||||
|
const expanded = settingsTab === tab.key
|
||||||
|
return (
|
||||||
|
<div key={tab.key} className="mb-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className={`flex items-center gap-2 flex-1 py-1.5 cursor-pointer rounded px-1.5 ${tab.key === 'overview' ? 'opacity-60' : 'hover:bg-gray-50'}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={visible}
|
||||||
|
onChange={() => toggleTabVisibility(tab.key)}
|
||||||
|
disabled={tab.key === 'overview'}
|
||||||
|
className="w-3.5 h-3.5 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-40"
|
||||||
|
/>
|
||||||
|
<TabIcon className="w-3.5 h-3.5 text-gray-400" />
|
||||||
|
<span className={`text-sm ${tab.key === 'overview' ? 'text-gray-400' : 'text-gray-700'}`}>{tab.label}</span>
|
||||||
|
{tab.key === 'overview' && <span className="text-[10px] text-gray-400">必选</span>}
|
||||||
|
</label>
|
||||||
|
{visible && sections.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSettingsTab(expanded ? '' : tab.key)}
|
||||||
|
className="text-gray-400 hover:text-gray-600 p-0.5"
|
||||||
|
>
|
||||||
|
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${expanded ? 'rotate-90' : ''}`} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{expanded && visible && sections.length > 0 && (
|
||||||
|
<div className="ml-6 mt-0.5 mb-1 space-y-0.5">
|
||||||
|
{sections.map(section => (
|
||||||
|
<label key={section.key} className="flex items-center gap-2 py-1 cursor-pointer hover:bg-gray-50 rounded px-1.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSectionVisible(section.key)}
|
||||||
|
onChange={() => toggleSectionVisibility(section.key)}
|
||||||
|
className="w-3.5 h-3.5 rounded border-gray-300 text-primary focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-600">{section.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab 导航 */}
|
{/* Tab 导航 */}
|
||||||
<div className="flex gap-1 border-b">
|
<div className="flex gap-1 border-b">
|
||||||
{tabs.map((tab) => {
|
{tabs.filter(tab => tabVisibility[tab.key] !== false).map((tab) => {
|
||||||
const Icon = tab.icon
|
const Icon = tab.icon
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -255,13 +381,31 @@ export default function Dashboard() {
|
|||||||
{/* 概览 Tab */}
|
{/* 概览 Tab */}
|
||||||
{activeTab === 'overview' && (
|
{activeTab === 'overview' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* 任务中心 */}
|
<PageGuide>
|
||||||
<TaskCenter />
|
概览页展示企业人力全局数据:在管员工数、待办事项、当月人力成本及年度累计成本。可点击各统计卡片快速跳转到对应详情页。右上角可自定义显示/隐藏模块。
|
||||||
|
</PageGuide>
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
{isSectionVisible('overview_stats') && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||||
|
{stats.map((stat) => {
|
||||||
|
const Icon = stat.icon
|
||||||
|
return (
|
||||||
|
<Card key={stat.label} className="flex items-center gap-2.5">
|
||||||
|
<Icon className={`w-6 h-6 ${stat.color}`} />
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-bold">{stat.value}</div>
|
||||||
|
<div className="text-xs text-gray-500">{stat.label}</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 合规健康度评分 + AI 建议卡片流 */}
|
{/* 合规健康度评分 + AI 建议卡片流 */}
|
||||||
{complianceScore && (
|
{isSectionVisible('overview_compliance') && isSectionVisible('overview_ai') && complianceScore && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* 评分环 + 5 维度 */}
|
{/* 评分环 + 维度 + 合同到期预警 */}
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{/* SVG 环形评分 */}
|
{/* SVG 环形评分 */}
|
||||||
@@ -283,22 +427,54 @@ export default function Dashboard() {
|
|||||||
<span className="text-xs text-gray-500 mt-1 truncate max-w-full">{complianceScore?.levelLabel ?? ''}</span>
|
<span className="text-xs text-gray-500 mt-1 truncate max-w-full">{complianceScore?.levelLabel ?? ''}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 5 维度评分 */}
|
{/* 维度评分 */}
|
||||||
<div className="flex-1 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
<div className="flex-1 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2">
|
||||||
{complianceScore?.dimensions?.map((dim: any) => (
|
{complianceScore?.dimensions?.map((dim: any) => (
|
||||||
<Link key={dim.key} to={dim.key === 'contract' ? '/roster' : dim.key === 'policy' ? '/policies' : dim.key === 'attendance' ? '/attendance' : dim.key === 'salary' ? '/money' : '/social'} className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50 transition-colors">
|
<Link key={dim.key} to={dim.key === 'contract' ? '/roster' : dim.key === 'policy' ? '/policies' : dim.key === 'attendance' ? '/attendance' : dim.key === 'salary' ? '/money' : '/social'} className="flex flex-col items-center p-1.5 rounded-lg hover:bg-gray-50 transition-colors">
|
||||||
<div className="flex items-center gap-2">
|
<span className={`text-lg font-bold ${dim.score >= 85 ? 'text-safe' : dim.score >= 60 ? 'text-warning' : 'text-danger'}`}>{dim.score}</span>
|
||||||
<div className={`w-2 h-2 rounded-full ${dim.score >= 85 ? 'bg-safe' : dim.score >= 60 ? 'bg-warning' : 'bg-danger'}`} />
|
<span className="text-xs text-gray-600">{dim.name}</span>
|
||||||
<span className="text-xs text-gray-700">{dim.name}</span>
|
{dim.todoCount > 0 && <span className="text-[10px] text-gray-400">{dim.todoCount}待办</span>}
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{dim.todoCount > 0 && <span className="text-xs text-gray-400">{dim.todoCount}待办</span>}
|
|
||||||
<span className={`text-sm font-bold ${dim.score >= 85 ? 'text-safe' : dim.score >= 60 ? 'text-warning' : 'text-danger'}`}>{dim.score}</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 合同到期预警内联 */}
|
||||||
|
{isSectionVisible('overview_expiring') && expiringContracts && expiringContracts.length > 0 && !dismissedExpiring && (
|
||||||
|
<div className="mt-3 pt-2 border-t flex items-center justify-between">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 cursor-pointer flex-1"
|
||||||
|
onClick={() => setShowExpiringModal(true)}
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-4 h-4 text-danger" />
|
||||||
|
<span className="text-sm font-medium text-danger">合同到期预警</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{expiringContracts.slice(0, 3).map((c: any, i: number) => (
|
||||||
|
<span key={c.employeeId}>
|
||||||
|
{i > 0 && '、'}
|
||||||
|
{c.employeeName}
|
||||||
|
<span className="text-danger ml-1">({c.daysLeft}天)</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{expiringContracts.length > 3 && <span className="text-gray-500"> 等{expiringContracts.length}人</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowExpiringModal(true)}
|
||||||
|
className="text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
去处理
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDismissedExpiring(true)}
|
||||||
|
className="text-gray-400 hover:text-gray-600 p-1"
|
||||||
|
title="稍后提醒"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* AI 建议卡片流 */}
|
{/* AI 建议卡片流 */}
|
||||||
@@ -348,25 +524,93 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 统计卡片 */}
|
{/* 快捷导航 */}
|
||||||
|
{isSectionVisible('overview_nav') && (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||||
{stats.map((stat) => {
|
<button onClick={() => setActiveTab('risk')} className="card flex items-center gap-2.5 p-3 hover:shadow-md transition-shadow text-left">
|
||||||
const Icon = stat.icon
|
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-red-50 text-danger">
|
||||||
return (
|
<AlertTriangle className="w-4 h-4" />
|
||||||
<Card key={stat.label} className="flex items-center gap-2.5">
|
</div>
|
||||||
<Icon className={`w-6 h-6 ${stat.color}`} />
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-base font-bold">{stat.value}</div>
|
<div className="text-base font-bold text-danger">{riskTodos.length}</div>
|
||||||
<div className="text-xs text-gray-500">{stat.label}</div>
|
<div className="text-xs text-gray-500">风险提醒</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setActiveTab('task')} className="card flex items-center gap-2.5 p-3 hover:shadow-md transition-shadow text-left">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-50 text-warning">
|
||||||
|
<ListTodo className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-bold text-warning">{taskTodos.length}</div>
|
||||||
|
<div className="text-xs text-gray-500">月度任务</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setActiveTab('cost')} className="card flex items-center gap-2.5 p-3 hover:shadow-md transition-shadow text-left">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-blue-50 text-blue-600">
|
||||||
|
<Wallet className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-bold text-blue-600">{fmt(monthTotalCost)}</div>
|
||||||
|
<div className="text-xs text-gray-500">人力成本</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setActiveTab('workforce')} className="card flex items-center gap-2.5 p-3 hover:shadow-md transition-shadow text-left">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-purple-50 text-purple-600">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-bold text-purple-600">{data.stats.employeeCount}</div>
|
||||||
|
<div className="text-xs text-gray-500">人员分析</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 人力成本 Tab */}
|
||||||
|
{activeTab === 'cost' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<PageGuide>
|
||||||
|
人力成本页展示当月及年度累计的工资、社保、公积金、加班费、补偿金等各项成本明细。可导出薪税汇总表,点击月份卡片可查看历史趋势。
|
||||||
|
</PageGuide>
|
||||||
|
{/* 成本专属指标卡片 */}
|
||||||
|
{isSectionVisible('cost_metrics') && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||||
|
<Card className="flex items-center gap-2.5">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-blue-50 text-blue-600"><Wallet className="w-4 h-4" /></div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-bold">{fmt(monthTotalCost)}</div>
|
||||||
|
<div className="text-xs text-gray-500">本月总成本</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
<Card className="flex items-center gap-2.5">
|
||||||
})}
|
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-green-50 text-safe"><TrendingUp className="w-4 h-4" /></div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-bold">{fmt(yearTotalCost)}</div>
|
||||||
|
<div className="text-xs text-gray-500">年度累计</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="flex items-center gap-2.5">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-purple-50 text-purple-600"><Users className="w-4 h-4" /></div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-bold">{fmt(costAnalysis?.current?.perCapita || 0)}</div>
|
||||||
|
<div className="text-xs text-gray-500">人均成本</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="flex items-center gap-2.5">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-50 text-warning"><Receipt className="w-4 h-4" /></div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-bold">{payroll?.payslipCount ?? 0}</div>
|
||||||
|
<div className="text-xs text-gray-500">工资条数</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 人力成本概览:当月 + 年度累计 */}
|
{/* 人力成本概览:当月 + 年度累计 */}
|
||||||
|
{isSectionVisible('cost_overview') && (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
{/* 当月人力成本 */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="text-sm font-medium flex items-center gap-1.5"><Wallet className="w-4 h-4 text-primary" />当月人力成本</h2>
|
<h2 className="text-sm font-medium flex items-center gap-1.5"><Wallet className="w-4 h-4 text-primary" />当月人力成本</h2>
|
||||||
@@ -386,7 +630,6 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 年度累计人力成本 */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="text-sm font-medium flex items-center gap-1.5"><TrendingUp className="w-4 h-4 text-primary" />年度累计成本</h2>
|
<h2 className="text-sm font-medium flex items-center gap-1.5"><TrendingUp className="w-4 h-4 text-primary" />年度累计成本</h2>
|
||||||
@@ -406,181 +649,10 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 合同到期预警 */}
|
|
||||||
{expiringContracts && expiringContracts.length > 0 && !dismissedExpiring && (
|
|
||||||
<Card className="border-danger/30 bg-danger/5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 cursor-pointer flex-1"
|
|
||||||
onClick={() => setShowExpiringModal(true)}
|
|
||||||
>
|
|
||||||
<AlertCircle className="w-5 h-5 text-danger" />
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-danger">合同到期预警</div>
|
|
||||||
<div className="text-xs text-gray-500 mt-0.5">
|
|
||||||
{expiringContracts.slice(0, 3).map((c: any, i: number) => (
|
|
||||||
<span key={c.employeeId}>
|
|
||||||
{i > 0 && '、'}
|
|
||||||
{c.employeeName}
|
|
||||||
<span className="text-danger ml-1">({c.daysLeft}天)</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{expiringContracts.length > 3 && <span className="text-gray-500"> 等{expiringContracts.length}人</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowExpiringModal(true)}
|
|
||||||
className="text-xs text-primary hover:underline"
|
|
||||||
>
|
|
||||||
去处理
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setDismissedExpiring(true)}
|
|
||||||
className="text-gray-400 hover:text-gray-600 p-1"
|
|
||||||
title="稍后提醒"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 本月工作动态 + 风险分布 左右两列 */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
|
||||||
{/* 本月工作动态 */}
|
|
||||||
<Card>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h2 className="text-sm font-medium flex items-center gap-1.5"><Briefcase className="w-4 h-4" />本月工作动态</h2>
|
|
||||||
<span className="text-xs text-gray-500">{activities?.month}</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
|
||||||
{activityItems.map((item) => {
|
|
||||||
const Icon = item.icon
|
|
||||||
return (
|
|
||||||
<div key={item.label} className="flex flex-col items-center p-2 rounded-lg bg-gray-50">
|
|
||||||
<Icon className={`w-4 h-4 mb-1 ${item.color}`} />
|
|
||||||
<div className="text-xs font-bold">{item.value}</div>
|
|
||||||
<div className="text-xs text-gray-500">{item.label}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 风险分布 */}
|
|
||||||
<Card>
|
|
||||||
<h2 className="text-sm font-medium mb-3">风险分布</h2>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-32 h-32 shrink-0">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={[
|
|
||||||
{ name: '合同风险', value: data.riskDistribution.contract, color: '#4F46E5' },
|
|
||||||
{ name: '薪资风险', value: data.riskDistribution.salary, color: '#F59E0B' },
|
|
||||||
{ name: '解聘风险', value: data.riskDistribution.termination, color: '#EF4444' },
|
|
||||||
].filter(d => d.value > 0)}
|
|
||||||
dataKey="value"
|
|
||||||
nameKey="name"
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
innerRadius={30}
|
|
||||||
outerRadius={55}
|
|
||||||
paddingAngle={2}
|
|
||||||
>
|
|
||||||
{[
|
|
||||||
{ name: '合同风险', value: data.riskDistribution.contract, color: '#4F46E5' },
|
|
||||||
{ name: '薪资风险', value: data.riskDistribution.salary, color: '#F59E0B' },
|
|
||||||
{ name: '解聘风险', value: data.riskDistribution.termination, color: '#EF4444' },
|
|
||||||
].filter(d => d.value > 0).map((entry, i) => (
|
|
||||||
<Cell key={i} fill={entry.color} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip formatter={(v: any) => `${v} 项`} />
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setDrillDownType(drillDownType === 'CONTRACT' ? null : 'CONTRACT')}
|
|
||||||
className={`flex items-center justify-between w-full p-2 rounded-lg transition-colors ${drillDownType === 'CONTRACT' ? 'bg-primary/10' : 'hover:bg-gray-50'}`}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="w-2.5 h-2.5 rounded-full bg-primary" />
|
|
||||||
合同风险
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-bold text-primary">{data.riskDistribution.contract}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setDrillDownType(drillDownType === 'SALARY' ? null : 'SALARY')}
|
|
||||||
className={`flex items-center justify-between w-full p-2 rounded-lg transition-colors ${drillDownType === 'SALARY' ? 'bg-warning/10' : 'hover:bg-gray-50'}`}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="w-2.5 h-2.5 rounded-full bg-warning" />
|
|
||||||
薪资风险
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-bold text-warning">{data.riskDistribution.salary}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setDrillDownType(drillDownType === 'TERMINATION' ? null : 'TERMINATION')}
|
|
||||||
className={`flex items-center justify-between w-full p-2 rounded-lg transition-colors ${drillDownType === 'TERMINATION' ? 'bg-danger/10' : 'hover:bg-gray-50'}`}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="w-2.5 h-2.5 rounded-full bg-danger" />
|
|
||||||
解聘风险
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-bold text-danger">{data.riskDistribution.termination}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</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-500 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="flex items-center gap-1.5">
|
|
||||||
<span className="truncate text-gray-800">{r.title}</span>
|
|
||||||
{r.priority && priorityConfig[r.priority] && (
|
|
||||||
<span className={`px-1 py-0.5 rounded text-xs font-medium ${priorityConfig[r.priority].bg} ${priorityConfig[r.priority].color}`}>
|
|
||||||
{priorityConfig[r.priority].label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-gray-500">
|
|
||||||
{r.employeeName && <span>{r.employeeName}</span>}
|
|
||||||
{r.estimatedLoss > 0 && <span className="text-red-600">损失 {fmt(r.estimatedLoss)}</span>}
|
|
||||||
{r.daysUntilDeadline !== null && r.daysUntilDeadline <= 7 && (
|
|
||||||
<span className="text-red-600">{r.daysUntilDeadline <= 0 ? '已逾期' : `${r.daysUntilDeadline}天`}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ArrowRight className="w-3 h-3 text-gray-500" />
|
|
||||||
</Link>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-gray-500 text-center py-2">暂无高风险项</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 人力成本分析 */}
|
{/* 人力成本分析 */}
|
||||||
<div className="grid grid-cols-1 gap-3">
|
{isSectionVisible('cost_analysis') && (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="text-sm font-medium flex items-center gap-1.5"><TrendingUp className="w-4 h-4 text-primary" />人力成本分析</h2>
|
<h2 className="text-sm font-medium flex items-center gap-1.5"><TrendingUp className="w-4 h-4 text-primary" />人力成本分析</h2>
|
||||||
@@ -648,10 +720,7 @@ export default function Dashboard() {
|
|||||||
<span className="font-medium text-gray-800">{fmt(d.totalCost)}</span>
|
<span className="font-medium text-gray-800">{fmt(d.totalCost)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||||
<div
|
<div className="h-full bg-primary/60 rounded-full" style={{ width: `${(d.totalCost / maxCost) * 100}%` }} />
|
||||||
className="h-full bg-primary/60 rounded-full"
|
|
||||||
style={{ width: `${(d.totalCost / maxCost) * 100}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-[10px] text-gray-400 mt-0.5">
|
<div className="flex justify-between text-[10px] text-gray-400 mt-0.5">
|
||||||
<span>工资 {fmt(d.totalPay)}</span>
|
<span>工资 {fmt(d.totalPay)}</span>
|
||||||
@@ -670,18 +739,10 @@ export default function Dashboard() {
|
|||||||
<div className="text-xs text-gray-500 text-center py-4">暂无成本分析数据</div>
|
<div className="text-xs text-gray-500 text-center py-4">暂无成本分析数据</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 入离职统计 + 绩效统计 */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3 mt-3">
|
|
||||||
<TurnoverStats />
|
|
||||||
<PerformanceStats />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 薪税 Tab */}
|
{/* 薪税明细 */}
|
||||||
{activeTab === 'payroll' && (
|
{isSectionVisible('cost_payroll') && (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="text-sm font-medium flex items-center gap-1.5"><Calculator className="w-4 h-4" />本月薪税费用总览</h2>
|
<h2 className="text-sm font-medium flex items-center gap-1.5"><Calculator className="w-4 h-4" />本月薪税费用总览</h2>
|
||||||
@@ -694,10 +755,8 @@ export default function Dashboard() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{payroll && payroll.payslipCount > 0 ? (
|
{payroll && payroll.payslipCount > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* 工资构成 */}
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-medium text-gray-600 mb-1.5">工资构成</div>
|
<div className="text-xs font-medium text-gray-600 mb-1.5">工资构成</div>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||||
@@ -715,14 +774,10 @@ export default function Dashboard() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 应发合计 */}
|
|
||||||
<div className="flex items-center justify-between border-t border-b py-2">
|
<div className="flex items-center justify-between border-t border-b py-2">
|
||||||
<span className="font-medium">应发合计</span>
|
<span className="font-medium">应发合计</span>
|
||||||
<span className="text-base font-bold text-primary">{fmt(payroll.totalPay)}</span>
|
<span className="text-base font-bold text-primary">{fmt(payroll.totalPay)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 扣减项 */}
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-medium text-gray-600 mb-1.5">扣减项</div>
|
<div className="text-xs font-medium text-gray-600 mb-1.5">扣减项</div>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
@@ -734,14 +789,10 @@ export default function Dashboard() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 员工实发 */}
|
|
||||||
<div className="flex items-center justify-between py-2">
|
<div className="flex items-center justify-between py-2">
|
||||||
<span className="font-medium flex items-center gap-2"><Wallet className="w-4 h-4 text-safe" />员工实发工资</span>
|
<span className="font-medium flex items-center gap-2"><Wallet className="w-4 h-4 text-safe" />员工实发工资</span>
|
||||||
<span className="text-base font-bold text-safe">{fmt(payroll.empNetPay)}</span>
|
<span className="text-base font-bold text-safe">{fmt(payroll.empNetPay)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 企业成本 */}
|
|
||||||
<div className="border-t pt-2 space-y-2">
|
<div className="border-t pt-2 space-y-2">
|
||||||
<div className="text-xs font-medium text-gray-600 mb-1">企业用工成本</div>
|
<div className="text-xs font-medium text-gray-600 mb-1">企业用工成本</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
@@ -769,8 +820,6 @@ export default function Dashboard() {
|
|||||||
<span className="text-base font-bold text-danger">{fmt(payroll.orgTotalCost)}</span>
|
<span className="text-base font-bold text-danger">{fmt(payroll.orgTotalCost)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 工资条确认状态 */}
|
|
||||||
<div className="flex items-center gap-3 text-xs border-t pt-2">
|
<div className="flex items-center gap-3 text-xs border-t pt-2">
|
||||||
<span className="text-gray-500">工资条确认:</span>
|
<span className="text-gray-500">工资条确认:</span>
|
||||||
<span className="text-safe">已确认 {payroll.confirmedPayslips}</span>
|
<span className="text-safe">已确认 {payroll.confirmedPayslips}</span>
|
||||||
@@ -783,9 +832,47 @@ export default function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 人员分析 Tab */}
|
||||||
|
{activeTab === 'workforce' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<PageGuide>
|
||||||
|
人员分析页展示本月入离职动态、员工分布及绩效统计。可按部门筛选查看人员构成,洞察团队变化趋势。
|
||||||
|
</PageGuide>
|
||||||
|
{/* 本月工作动态 */}
|
||||||
|
{isSectionVisible('workforce_activities') && (
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-sm font-medium flex items-center gap-1.5"><Briefcase className="w-4 h-4" />本月工作动态</h2>
|
||||||
|
<span className="text-xs text-gray-500">{activities?.month}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-2">
|
||||||
|
{activityItems.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
return (
|
||||||
|
<div key={item.label} className="flex flex-col items-center p-2 rounded-lg bg-gray-50">
|
||||||
|
<Icon className={`w-4 h-4 mb-1 ${item.color}`} />
|
||||||
|
<div className="text-xs font-bold">{item.value}</div>
|
||||||
|
<div className="text-xs text-gray-500">{item.label}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 入离职统计 + 绩效统计 */}
|
||||||
|
{isSectionVisible('workforce_turnover') && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
|
<TurnoverStats />
|
||||||
|
<PerformanceStats />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 员工分布统计 */}
|
{/* 员工分布统计 */}
|
||||||
{activeTab === 'overview' && workforceStats && workforceStats.total > 0 && (
|
{isSectionVisible('workforce_distribution') && workforceStats && workforceStats.total > 0 && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
{/* 性别分布 */}
|
{/* 性别分布 */}
|
||||||
<Card>
|
<Card>
|
||||||
@@ -880,11 +967,127 @@ export default function Dashboard() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 风险提醒 Tab */}
|
{/* 风险提醒 Tab */}
|
||||||
{(activeTab === 'risk' || activeTab === 'task') && (
|
{(activeTab === 'risk' || activeTab === 'task') && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<PageGuide>
|
||||||
|
{activeTab === 'risk'
|
||||||
|
? '风险提醒页汇总合同到期、未签合同、离职风险、退休预警等高风险待办事项。可批量处理或逐项解决,处理完成后自动归档至已办事项。'
|
||||||
|
: '月度任务页汇总社保办理、发薪批次等周期性待办事项。可批量处理或逐项解决,处理完成后自动归档至已办事项。'}
|
||||||
|
</PageGuide>
|
||||||
|
{/* 风险分布饼图(仅风险提醒Tab) */}
|
||||||
|
{activeTab === 'risk' && isSectionVisible('risk_distribution') && (
|
||||||
|
<Card>
|
||||||
|
<h2 className="text-sm font-medium mb-3">风险分布</h2>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-32 h-32 shrink-0">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={[
|
||||||
|
{ name: '合同风险', value: data.riskDistribution.contract, color: '#4F46E5' },
|
||||||
|
{ name: '薪资风险', value: data.riskDistribution.salary, color: '#F59E0B' },
|
||||||
|
{ name: '解聘风险', value: data.riskDistribution.termination, color: '#EF4444' },
|
||||||
|
].filter(d => d.value > 0)}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={30}
|
||||||
|
outerRadius={55}
|
||||||
|
paddingAngle={2}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ name: '合同风险', value: data.riskDistribution.contract, color: '#4F46E5' },
|
||||||
|
{ name: '薪资风险', value: data.riskDistribution.salary, color: '#F59E0B' },
|
||||||
|
{ name: '解聘风险', value: data.riskDistribution.termination, color: '#EF4444' },
|
||||||
|
].filter(d => d.value > 0).map((entry, i) => (
|
||||||
|
<Cell key={i} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip formatter={(v: any) => `${v} 项`} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setDrillDownType(drillDownType === 'CONTRACT' ? null : 'CONTRACT')}
|
||||||
|
className={`flex items-center justify-between w-full p-2 rounded-lg transition-colors ${drillDownType === 'CONTRACT' ? 'bg-primary/10' : 'hover:bg-gray-50'}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-primary" />
|
||||||
|
合同风险
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold text-primary">{data.riskDistribution.contract}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDrillDownType(drillDownType === 'SALARY' ? null : 'SALARY')}
|
||||||
|
className={`flex items-center justify-between w-full p-2 rounded-lg transition-colors ${drillDownType === 'SALARY' ? 'bg-warning/10' : 'hover:bg-gray-50'}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-warning" />
|
||||||
|
薪资风险
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold text-warning">{data.riskDistribution.salary}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDrillDownType(drillDownType === 'TERMINATION' ? null : 'TERMINATION')}
|
||||||
|
className={`flex items-center justify-between w-full p-2 rounded-lg transition-colors ${drillDownType === 'TERMINATION' ? 'bg-danger/10' : 'hover:bg-gray-50'}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-danger" />
|
||||||
|
解聘风险
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold text-danger">{data.riskDistribution.termination}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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-500 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="flex items-center gap-1.5">
|
||||||
|
<span className="truncate text-gray-800">{r.title}</span>
|
||||||
|
{r.priority && priorityConfig[r.priority] && (
|
||||||
|
<span className={`px-1 py-0.5 rounded text-xs font-medium ${priorityConfig[r.priority].bg} ${priorityConfig[r.priority].color}`}>
|
||||||
|
{priorityConfig[r.priority].label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-gray-500">
|
||||||
|
{r.employeeName && <span>{r.employeeName}</span>}
|
||||||
|
{r.estimatedLoss > 0 && <span className="text-red-600">损失 {fmt(r.estimatedLoss)}</span>}
|
||||||
|
{r.daysUntilDeadline !== null && r.daysUntilDeadline <= 7 && (
|
||||||
|
<span className="text-red-600">{r.daysUntilDeadline <= 0 ? '已逾期' : `${r.daysUntilDeadline}天`}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="w-3 h-3 text-gray-500" />
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-gray-500 text-center py-2">暂无高风险项</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 待办列表 */}
|
{/* 待办列表 */}
|
||||||
|
{isSectionVisible(activeTab === 'risk' ? 'risk_todos' : 'task_todos') && (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="text-sm font-medium">{activeTab === 'risk' ? '风险提醒' : '月度任务'}</h2>
|
<h2 className="text-sm font-medium">{activeTab === 'risk' ? '风险提醒' : '月度任务'}</h2>
|
||||||
@@ -996,16 +1199,24 @@ export default function Dashboard() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 已办事项 */}
|
{/* 已办事项 */}
|
||||||
{data.resolvedTodos && data.resolvedTodos.length > 0 && (
|
{isSectionVisible(activeTab === 'risk' ? 'risk_resolved' : 'task_resolved') && (() => {
|
||||||
|
const riskTypes = ['CONTRACT', 'TERMINATION', 'ONBOARDING', 'RETIREMENT']
|
||||||
|
const taskTypes = ['MONTHLY', 'SALARY']
|
||||||
|
const resolvedFiltered = (data.resolvedTodos || []).filter(t =>
|
||||||
|
activeTab === 'risk' ? riskTypes.includes(t.type) : taskTypes.includes(t.type)
|
||||||
|
)
|
||||||
|
if (resolvedFiltered.length === 0) return null
|
||||||
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="text-sm font-medium flex items-center gap-1.5"><CheckSquare className="w-4 h-4 text-safe" />已办事项</h2>
|
<h2 className="text-sm font-medium flex items-center gap-1.5"><CheckSquare className="w-4 h-4 text-safe" />已办事项</h2>
|
||||||
<span className="text-xs text-gray-500">{data.resolvedTodos.length} 项</span>
|
<span className="text-xs text-gray-500">{resolvedFiltered.length} 项</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{data.resolvedTodos.map((todo) => (
|
{resolvedFiltered.map((todo) => (
|
||||||
<div
|
<div
|
||||||
key={todo.id}
|
key={todo.id}
|
||||||
className="flex items-center justify-between px-2.5 py-2 rounded-md bg-gray-50"
|
className="flex items-center justify-between px-2.5 py-2 rounded-md bg-gray-50"
|
||||||
@@ -1024,7 +1235,8 @@ export default function Dashboard() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { ShieldCheck, FileText, AlertCircle, CheckCircle, XCircle } from 'lucide-react'
|
import { ShieldCheck, FileText, CheckCircle, XCircle } from 'lucide-react'
|
||||||
import { evidenceApi } from '../lib/api-services'
|
import { evidenceApi } from '../lib/api-services'
|
||||||
import Card from '../components/ui/Card'
|
import Card from '../components/ui/Card'
|
||||||
import EmptyState from '../components/ui/EmptyState'
|
import EmptyState from '../components/ui/EmptyState'
|
||||||
import Pagination from '../components/ui/Pagination'
|
import Pagination from '../components/ui/Pagination'
|
||||||
|
import PageGuide from '../components/ui/PageGuide'
|
||||||
|
import QueryError from '../components/ui/QueryError'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 证据链管理页面
|
* 证据链管理页面
|
||||||
@@ -14,7 +16,7 @@ export default function Evidence() {
|
|||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [pageSize, setPageSize] = useState(20)
|
const [pageSize, setPageSize] = useState(20)
|
||||||
|
|
||||||
const { data: listData, isLoading } = useQuery<any>({
|
const { data: listData, isLoading, isError, error, refetch } = useQuery<any>({
|
||||||
queryKey: ['evidence', refType, page, pageSize],
|
queryKey: ['evidence', refType, page, pageSize],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params: any = { page, pageSize }
|
const params: any = { page, pageSize }
|
||||||
@@ -35,6 +37,9 @@ export default function Evidence() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<PageGuide>
|
||||||
|
证据链管理记录系统中所有关键操作的不可篡改证据,用于劳动仲裁举证。支持按关联类型筛选、验证证据完整性。点击「验证全部完整性」可批量校验所有证据是否被篡改。
|
||||||
|
</PageGuide>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -82,6 +87,8 @@ export default function Evidence() {
|
|||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8 text-gray-500">加载中...</div>
|
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||||||
|
) : isError ? (
|
||||||
|
<QueryError error={error} onRetry={refetch} />
|
||||||
) : !list || list.length === 0 ? (
|
) : !list || list.length === 0 ? (
|
||||||
<EmptyState title="暂无证据链记录" description="系统操作将自动生成证据链" />
|
<EmptyState title="暂无证据链记录" description="系统操作将自动生成证据链" />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useConfirm } from '../hooks/useConfirm'
|
||||||
|
import { CalendarClock, Check, X, Trash2, Clock, CheckCircle2, XCircle, RotateCcw, FileText, Smartphone } from 'lucide-react'
|
||||||
|
import { leaveApi } from '../lib/api-services'
|
||||||
|
import Card from '../components/ui/Card'
|
||||||
|
import Button from '../components/ui/Button'
|
||||||
|
import { Input, Label, Select } from '../components/ui/Input'
|
||||||
|
import EmptyState from '../components/ui/EmptyState'
|
||||||
|
import Pagination from '../components/ui/Pagination'
|
||||||
|
|
||||||
|
const LEAVE_TYPE_MAP: Record<string, string> = {
|
||||||
|
SICK: '病假',
|
||||||
|
PERSONAL: '事假',
|
||||||
|
ANNUAL: '年假',
|
||||||
|
MATERNITY: '产假',
|
||||||
|
OTHER: '其他',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
|
||||||
|
PENDING: { label: '待审批', color: 'bg-amber-50 text-warning', icon: <Clock className="w-3 h-3" /> },
|
||||||
|
APPROVED: { label: '已批准', color: 'bg-green-50 text-safe', icon: <CheckCircle2 className="w-3 h-3" /> },
|
||||||
|
REJECTED: { label: '已驳回', color: 'bg-red-50 text-danger', icon: <XCircle className="w-3 h-3" /> },
|
||||||
|
CANCELLED: { label: '已撤回', color: 'bg-gray-100 text-gray-500', icon: <RotateCcw className="w-3 h-3" /> },
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmtDate = (d: string) => new Date(d).toLocaleDateString('zh-CN')
|
||||||
|
|
||||||
|
export default function LeaveApproval() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(20)
|
||||||
|
const [filterStatus, setFilterStatus] = useState('')
|
||||||
|
const [filterType, setFilterType] = useState('')
|
||||||
|
const [approveModal, setApproveModal] = useState<{ id: string; action: string; name: string } | null>(null)
|
||||||
|
const [approveRemark, setApproveRemark] = useState('')
|
||||||
|
|
||||||
|
const { data: stats } = useQuery<any>({
|
||||||
|
queryKey: ['leave-stats'],
|
||||||
|
queryFn: () => leaveApi.stats(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: result, isLoading } = useQuery<any>({
|
||||||
|
queryKey: ['leave-requests', filterStatus, filterType, page, pageSize],
|
||||||
|
queryFn: () => leaveApi.list({ status: filterStatus || undefined, leaveType: filterType || undefined, page, pageSize }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = result?.list || []
|
||||||
|
const total = result?.total || 0
|
||||||
|
|
||||||
|
const approveMutation = useMutation({
|
||||||
|
mutationFn: ({ id, action, remark }: { id: string; action: string; remark?: string }) =>
|
||||||
|
leaveApi.approve(id, action, remark),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['leave-requests'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['leave-stats'] })
|
||||||
|
toast.success('审批完成')
|
||||||
|
setApproveModal(null)
|
||||||
|
setApproveRemark('')
|
||||||
|
},
|
||||||
|
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '审批失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => leaveApi.remove(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['leave-requests'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['leave-stats'] })
|
||||||
|
toast.success('已删除')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CalendarClock className="w-5 h-5 text-primary" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-base font-semibold">休假审批</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">员工端申请 → 管理端审批 → 自动归档</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 流程说明 */}
|
||||||
|
<div className="bg-blue-50 rounded-lg px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Smartphone className="w-4 h-4 text-blue-600 flex-shrink-0" />
|
||||||
|
<span className="text-xs font-medium text-blue-700">休假审批流程</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-blue-600 leading-relaxed">
|
||||||
|
① 员工在手机端「员工自助」发起休假申请(选择类型、日期、事由)<br/>
|
||||||
|
② 管理端在此页面审批:批准或驳回(可填写审批意见)<br/>
|
||||||
|
③ 批准后自动生成休假记录,归档至考勤系统
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||||
|
<Card className="flex items-center gap-2">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-blue-50 flex items-center justify-center"><FileText className="w-4 h-4 text-blue-600" /></div>
|
||||||
|
<div><div className="text-sm font-bold">{stats?.total || 0}</div><div className="text-xs text-gray-500">总计</div></div>
|
||||||
|
</Card>
|
||||||
|
<Card className="flex items-center gap-2">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-amber-50 flex items-center justify-center"><Clock className="w-4 h-4 text-amber-600" /></div>
|
||||||
|
<div><div className="text-sm font-bold text-warning">{stats?.pending || 0}</div><div className="text-xs text-gray-500">待审批</div></div>
|
||||||
|
</Card>
|
||||||
|
<Card className="flex items-center gap-2">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-green-50 flex items-center justify-center"><CheckCircle2 className="w-4 h-4 text-green-600" /></div>
|
||||||
|
<div><div className="text-sm font-bold text-safe">{stats?.approved || 0}</div><div className="text-xs text-gray-500">已批准</div></div>
|
||||||
|
</Card>
|
||||||
|
<Card className="flex items-center gap-2">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-red-50 flex items-center justify-center"><XCircle className="w-4 h-4 text-red-600" /></div>
|
||||||
|
<div><div className="text-sm font-bold text-danger">{stats?.rejected || 0}</div><div className="text-xs text-gray-500">已驳回</div></div>
|
||||||
|
</Card>
|
||||||
|
<Card className="flex items-center gap-2">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-gray-100 flex items-center justify-center"><RotateCcw className="w-4 h-4 text-gray-500" /></div>
|
||||||
|
<div><div className="text-sm font-bold text-gray-500">{stats?.cancelled || 0}</div><div className="text-xs text-gray-500">已撤回</div></div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 筛选 + 操作 */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Select value={filterStatus} onChange={(e) => { setFilterStatus(e.target.value); setPage(1) }} className="!w-28">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="PENDING">待审批</option>
|
||||||
|
<option value="APPROVED">已批准</option>
|
||||||
|
<option value="REJECTED">已驳回</option>
|
||||||
|
<option value="CANCELLED">已撤回</option>
|
||||||
|
</Select>
|
||||||
|
<Select value={filterType} onChange={(e) => { setFilterType(e.target.value); setPage(1) }} className="!w-28">
|
||||||
|
<option value="">全部类型</option>
|
||||||
|
{Object.entries(LEAVE_TYPE_MAP).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||||
|
</Select>
|
||||||
|
{total > 0 && <span className="text-xs text-gray-500">{total} 条记录</span>}
|
||||||
|
<div className="flex-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 列表 */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||||||
|
) : list.length === 0 ? (
|
||||||
|
<EmptyState title="暂无休假申请" description="员工在手机端提交的休假申请将显示在此处" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
|
||||||
|
<Card>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-xs text-gray-500">
|
||||||
|
<th className="py-2 px-3">员工</th>
|
||||||
|
<th className="py-2 px-3">类型</th>
|
||||||
|
<th className="py-2 px-3">起止日期</th>
|
||||||
|
<th className="py-2 px-3 text-right">天数</th>
|
||||||
|
<th className="py-2 px-3">事由</th>
|
||||||
|
<th className="py-2 px-3">状态</th>
|
||||||
|
<th className="py-2 px-3">审批意见</th>
|
||||||
|
<th className="py-2 px-3 text-right">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{list.map((item: any) => {
|
||||||
|
const st = STATUS_MAP[item.status] || STATUS_MAP.PENDING
|
||||||
|
return (
|
||||||
|
<tr key={item.id} className="border-b last:border-0 hover:bg-gray-50">
|
||||||
|
<td className="py-2.5 px-3">
|
||||||
|
<div className="text-xs font-medium">{item.employee?.name}</div>
|
||||||
|
<div className="text-xs text-gray-500">{item.employee?.department}</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-xs">{LEAVE_TYPE_MAP[item.leaveType] || item.leaveType}</td>
|
||||||
|
<td className="py-2.5 px-3 text-xs text-gray-600">
|
||||||
|
{fmtDate(item.startDate)} ~ {fmtDate(item.endDate)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right text-sm font-medium">{item.days}</td>
|
||||||
|
<td className="py-2.5 px-3 text-xs text-gray-600 max-w-32 truncate" title={item.reason || ''}>{item.reason || '—'}</td>
|
||||||
|
<td className="py-2.5 px-3">
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs ${st.color}`}>
|
||||||
|
{st.icon}{st.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-xs text-gray-500 max-w-32 truncate" title={item.approveRemark || ''}>{item.approveRemark || '—'}</td>
|
||||||
|
<td className="py-2.5 px-3">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
{item.status === 'PENDING' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setApproveModal({ id: item.id, action: 'APPROVED', name: item.employee?.name })}
|
||||||
|
className="p-1 rounded hover:bg-green-50 text-gray-500 hover:text-safe transition-colors"
|
||||||
|
title="批准"
|
||||||
|
>
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setApproveModal({ id: item.id, action: 'REJECTED', name: item.employee?.name })}
|
||||||
|
className="p-1 rounded hover:bg-red-50 text-gray-500 hover:text-danger transition-colors"
|
||||||
|
title="驳回"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (await confirm({ title: '删除申请', message: '确认删除该休假申请?此操作不可撤销。' })) {
|
||||||
|
deleteMutation.mutate(item.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-1 rounded hover:bg-red-50 text-gray-500 hover:text-danger transition-colors"
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 审批弹窗 */}
|
||||||
|
{approveModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setApproveModal(null)}>
|
||||||
|
<Card className="max-w-md w-full" >
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-medium">
|
||||||
|
{approveModal.action === 'APPROVED' ? '批准休假申请' : '驳回休假申请'} — {approveModal.name}
|
||||||
|
</h3>
|
||||||
|
<button onClick={() => setApproveModal(null)} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label>审批意见</Label>
|
||||||
|
<Input value={approveRemark} onChange={(e) => setApproveRemark(e.target.value)} placeholder="请输入审批意见(可选)" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant={approveModal.action === 'APPROVED' ? 'primary' : 'danger'}
|
||||||
|
onClick={() => approveMutation.mutate({ id: approveModal.id, action: approveModal.action, remark: approveRemark })}
|
||||||
|
disabled={approveMutation.isPending}
|
||||||
|
>
|
||||||
|
{approveMutation.isPending ? '处理中...' : approveModal.action === 'APPROVED' ? '确认批准' : '确认驳回'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => setApproveModal(null)}>取消</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+8
-2084
File diff suppressed because it is too large
Load Diff
@@ -155,7 +155,6 @@ export default function Notifications() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SettingsModal({ settings, onClose, onSuccess }: { settings: any; onClose: () => void; onSuccess: () => void }) {
|
function SettingsModal({ settings, onClose, onSuccess }: { settings: any; onClose: () => void; onSuccess: () => void }) {
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
contractExpiry: settings.contractExpiry ?? true,
|
contractExpiry: settings.contractExpiry ?? true,
|
||||||
expiryDays: settings.expiryDays ?? 30,
|
expiryDays: settings.expiryDays ?? 30,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import Card from '../components/ui/Card'
|
|||||||
import Button from '../components/ui/Button'
|
import Button from '../components/ui/Button'
|
||||||
import EmptyState from '../components/ui/EmptyState'
|
import EmptyState from '../components/ui/EmptyState'
|
||||||
import Pagination from '../components/ui/Pagination'
|
import Pagination from '../components/ui/Pagination'
|
||||||
|
import PageGuide from '../components/ui/PageGuide'
|
||||||
|
import QueryError from '../components/ui/QueryError'
|
||||||
|
|
||||||
const STEP_LABELS: Record<string, string> = {
|
const STEP_LABELS: Record<string, string> = {
|
||||||
DRAFTING: '起草',
|
DRAFTING: '起草',
|
||||||
@@ -27,7 +29,7 @@ export default function Policies() {
|
|||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [pageSize, setPageSize] = useState(20)
|
const [pageSize, setPageSize] = useState(20)
|
||||||
|
|
||||||
const { data: listData, isLoading } = useQuery<any>({
|
const { data: listData, isLoading, isError, error, refetch } = useQuery<any>({
|
||||||
queryKey: ['policies', page, pageSize],
|
queryKey: ['policies', page, pageSize],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return await policiesApi.list({ page, pageSize })
|
return await policiesApi.list({ page, pageSize })
|
||||||
@@ -51,6 +53,9 @@ export default function Policies() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<PageGuide>
|
||||||
|
规章制度管理遵循民主程序四步法:起草 → 讨论 → 协商 → 公示。每一步均需记录参与人员、会议纪要及员工签字,确保制度合法有效。新建制度后按步骤推进,系统自动生成民主程序履行记录。
|
||||||
|
</PageGuide>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -66,6 +71,8 @@ export default function Policies() {
|
|||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8 text-gray-500">加载中...</div>
|
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||||||
|
) : isError ? (
|
||||||
|
<QueryError error={error} onRetry={refetch} />
|
||||||
) : !list || list.length === 0 ? (
|
) : !list || list.length === 0 ? (
|
||||||
<EmptyState title="暂无规章制度" description="点击右上角新建制度" />
|
<EmptyState title="暂无规章制度" description="点击右上角新建制度" />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router-dom'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useConfirm } from '../hooks/useConfirm'
|
import { useConfirm } from '../hooks/useConfirm'
|
||||||
import { Users, FileText, AlertTriangle, Calendar, TrendingUp, Scale, X, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, History, Upload, Wallet, Download } from 'lucide-react'
|
import { Users, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, Upload, Wallet, Download } from 'lucide-react'
|
||||||
import { rosterApi, employeeApi, terminationApi } from '../lib/api-services'
|
import { rosterApi, employeeApi, terminationApi } from '../lib/api-services'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
import { useDebouncedValue } from '../hooks/useDebouncedValue'
|
import { useDebouncedValue } from '../hooks/useDebouncedValue'
|
||||||
@@ -12,11 +12,13 @@ import Button from '../components/ui/Button'
|
|||||||
import { Input, Label, Select } from '../components/ui/Input'
|
import { Input, Label, Select } from '../components/ui/Input'
|
||||||
import Modal from '../components/ui/Modal'
|
import Modal from '../components/ui/Modal'
|
||||||
import Pagination from '../components/ui/Pagination'
|
import Pagination from '../components/ui/Pagination'
|
||||||
import { fmt, terminateReasonMap, DetailTab, TAB_GROUPS, TAB_COUNT_KEYS } from './roster/shared'
|
import { fmt, terminateReasonMap } from './roster/shared'
|
||||||
|
import PageGuide from '../components/ui/PageGuide'
|
||||||
import EmployeeProfile from './roster/EmployeeProfile'
|
import EmployeeProfile from './roster/EmployeeProfile'
|
||||||
import { InlineAlert } from '../components/ui/InlineAlert'
|
import { InlineAlert } from '../components/ui/InlineAlert'
|
||||||
import { AddEmployeeModal, ResignModal, RehireModal, SalaryChangeModal, DeptChangeModal } from './roster/modals'
|
import { AddEmployeeModal, ResignModal, RehireModal, SalaryChangeModal, DeptChangeModal } from './roster/modals'
|
||||||
import { ImportSettings } from './Settings'
|
import { ImportSettings } from './Settings'
|
||||||
|
import QueryError from '../components/ui/QueryError'
|
||||||
|
|
||||||
export default function Roster() {
|
export default function Roster() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -49,7 +51,7 @@ export default function Roster() {
|
|||||||
const [batchTerminateReason, setBatchTerminateReason] = useState('NEGOTIATED')
|
const [batchTerminateReason, setBatchTerminateReason] = useState('NEGOTIATED')
|
||||||
const [terminatePreviewData, setTerminatePreviewData] = useState<any>(null)
|
const [terminatePreviewData, setTerminatePreviewData] = useState<any>(null)
|
||||||
|
|
||||||
const { data: rosterData, isLoading } = useQuery<any>({
|
const { data: rosterData, isLoading, isError, error, refetch } = useQuery<any>({
|
||||||
queryKey: ['roster', page, pageSize, debouncedSearch, filterStatus, filterContractStatus, filterDepartment],
|
queryKey: ['roster', page, pageSize, debouncedSearch, filterStatus, filterContractStatus, filterDepartment],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params: any = { page, pageSize }
|
const params: any = { page, pageSize }
|
||||||
@@ -272,6 +274,9 @@ export default function Roster() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
|
<PageGuide>
|
||||||
|
员工花名册统一管理所有员工档案信息,包括基本信息、合同、薪酬、社保等。支持搜索、筛选、导出。点击员工可查看详细档案。支持入职登记、离职办理、合同签订等操作。
|
||||||
|
</PageGuide>
|
||||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -407,6 +412,8 @@ export default function Roster() {
|
|||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="rounded-lg border border-gray-200 bg-white py-16 text-center text-sm text-gray-400">加载中...</div>
|
<div className="rounded-lg border border-gray-200 bg-white py-16 text-center text-sm text-gray-400">加载中...</div>
|
||||||
|
) : isError ? (
|
||||||
|
<QueryError error={error} onRetry={refetch} />
|
||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<Card><div className="py-12 text-center text-sm text-gray-400">暂无符合条件的员工</div></Card>
|
<Card><div className="py-12 text-center text-sm text-gray-400">暂无符合条件的员工</div></Card>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
import Card from '../components/ui/Card'
|
import Card from '../components/ui/Card'
|
||||||
import { Select } from '../components/ui/Input'
|
import { Select } from '../components/ui/Input'
|
||||||
import { InlineAlert } from '../components/ui/InlineAlert'
|
import { InlineAlert } from '../components/ui/InlineAlert'
|
||||||
|
import PageGuide from '../components/ui/PageGuide'
|
||||||
|
import QueryError from '../components/ui/QueryError'
|
||||||
import { salaryDashboardApi } from '../lib/api-services'
|
import { salaryDashboardApi } from '../lib/api-services'
|
||||||
|
|
||||||
/** 金额格式化 */
|
/** 金额格式化 */
|
||||||
@@ -18,7 +20,7 @@ export default function SalaryDashboard() {
|
|||||||
const [year, setYear] = useState(new Date().getFullYear().toString())
|
const [year, setYear] = useState(new Date().getFullYear().toString())
|
||||||
|
|
||||||
/** 获取薪酬分析数据 */
|
/** 获取薪酬分析数据 */
|
||||||
const { data, isLoading } = useQuery<any>({
|
const { data, isLoading, isError, error, refetch } = useQuery<any>({
|
||||||
queryKey: ['salary-dashboard', year],
|
queryKey: ['salary-dashboard', year],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return await salaryDashboardApi.data(Number(year))
|
return await salaryDashboardApi.data(Number(year))
|
||||||
@@ -37,6 +39,9 @@ export default function SalaryDashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<PageGuide>
|
||||||
|
薪酬分析看板展示企业薪酬分布、部门间薪酬对比及年度趋势变化。选择年份后查看各部门薪酬数据、同比环比变化。用于辅助薪酬决策和成本控制。
|
||||||
|
</PageGuide>
|
||||||
{/* 页头 */}
|
{/* 页头 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -55,6 +60,8 @@ export default function SalaryDashboard() {
|
|||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||||
|
) : isError ? (
|
||||||
|
<QueryError error={error} onRetry={refetch} />
|
||||||
) : !data ? (
|
) : !data ? (
|
||||||
<Card><div className="text-center py-8 text-gray-400 text-sm">暂无数据</div></Card>
|
<Card><div className="text-center py-8 text-gray-400 text-sm">暂无数据</div></Card>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Card from '../components/ui/Card'
|
|||||||
import Button from '../components/ui/Button'
|
import Button from '../components/ui/Button'
|
||||||
import { Input, Label, Select } from '../components/ui/Input'
|
import { Input, Label, Select } from '../components/ui/Input'
|
||||||
import Modal from '../components/ui/Modal'
|
import Modal from '../components/ui/Modal'
|
||||||
|
import { useConfirm } from '../hooks/useConfirm'
|
||||||
|
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
@@ -618,6 +619,7 @@ function handleAcceptanceExport() {
|
|||||||
|
|
||||||
function PlanSettings({ orgData }: { orgData: any }) {
|
function PlanSettings({ orgData }: { orgData: any }) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const confirm = useConfirm()
|
||||||
const plan = orgData?.plan || 'FREE'
|
const plan = orgData?.plan || 'FREE'
|
||||||
|
|
||||||
const { data: usageData } = useQuery<any>({
|
const { data: usageData } = useQuery<any>({
|
||||||
@@ -683,8 +685,8 @@ function PlanSettings({ orgData }: { orgData: any }) {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
if (confirm(`确定切换到${p.label}?`)) planMutation.mutate(p.key)
|
if (await confirm({ title: '切换套餐', message: `确定切换到${p.label}?`, variant: 'primary' })) planMutation.mutate(p.key)
|
||||||
}}
|
}}
|
||||||
disabled={planMutation.isPending}
|
disabled={planMutation.isPending}
|
||||||
>
|
>
|
||||||
@@ -959,8 +961,14 @@ function InitImport() {
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
setError(data.error?.message || '导入失败')
|
setError(data.error?.message || '导入失败')
|
||||||
|
toast.error(data.error?.message || '导入失败')
|
||||||
} else {
|
} else {
|
||||||
setResult(data.data)
|
setResult(data.data)
|
||||||
|
if (data.data.errors?.length > 0) {
|
||||||
|
toast.warning(`导入完成,但有 ${data.data.errors.length} 条错误,请查看详情`)
|
||||||
|
} else {
|
||||||
|
toast.success(`成功导入员工 ${data.data.employees} 人`)
|
||||||
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||||
}
|
}
|
||||||
@@ -1123,6 +1131,22 @@ function InitImport() {
|
|||||||
{result.errors.length > 10 && <div className="text-amber-600">...还有 {result.errors.length - 10} 条,请导出错误日志查看全部</div>}
|
{result.errors.length > 10 && <div className="text-amber-600">...还有 {result.errors.length - 10} 条,请导出错误日志查看全部</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* 导入明细 */}
|
||||||
|
{result.details?.filter((d: any) => d.status === 'success').length > 0 && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-green-200">
|
||||||
|
<div className="font-medium text-gray-600 mb-1">导入明细:</div>
|
||||||
|
<div className="max-h-40 overflow-y-auto">
|
||||||
|
{result.details.filter((d: any) => d.status === 'success').map((d: any, i: number) => (
|
||||||
|
<div key={i} className="flex gap-3 text-xs text-gray-500">
|
||||||
|
<span>第{d.row}行</span>
|
||||||
|
<span>{d.name}</span>
|
||||||
|
{d.employeeId && <span className="text-gray-400">ID: {d.employeeId.slice(0, 8)}...</span>}
|
||||||
|
<span className="text-safe">✓ {d.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1175,8 +1199,14 @@ function MonthlyImport() {
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
setError(data.error?.message || '导入失败')
|
setError(data.error?.message || '导入失败')
|
||||||
|
toast.error(data.error?.message || '导入失败')
|
||||||
} else {
|
} else {
|
||||||
setResult(data.data)
|
setResult(data.data)
|
||||||
|
if (data.data.errors?.length > 0) {
|
||||||
|
toast.warning(`导入完成,但有 ${data.data.errors.length} 条错误,请查看详情`)
|
||||||
|
} else {
|
||||||
|
toast.success('月度数据导入成功')
|
||||||
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useConfirm } from '../hooks/useConfirm'
|
import { useConfirm } from '../hooks/useConfirm'
|
||||||
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download, AlertCircle, Clock, MapPin, Sparkles, Upload, X, Shield } from 'lucide-react'
|
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download, AlertCircle, Clock, MapPin, Sparkles } from 'lucide-react'
|
||||||
import { InlineAlert } from '../components/ui/InlineAlert'
|
import { InlineAlert } from '../components/ui/InlineAlert'
|
||||||
import { socialInsuranceApi, commercialInsuranceApi } from '../lib/api-services'
|
import PageGuide from '../components/ui/PageGuide'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { socialInsuranceApi } from '../lib/api-services'
|
||||||
import Card from '../components/ui/Card'
|
import Card from '../components/ui/Card'
|
||||||
import Button from '../components/ui/Button'
|
import Button from '../components/ui/Button'
|
||||||
import { Input, Label } from '../components/ui/Input'
|
import { Input, Label } from '../components/ui/Input'
|
||||||
|
import { MonthlyRow, MonthlyHousingRow } from './social-insurance/MonthlyRows'
|
||||||
|
import SpecialDeductionTab from './social-insurance/SpecialDeductionTab'
|
||||||
|
import CommercialInsuranceTab from './social-insurance/CommercialInsuranceTab'
|
||||||
|
|
||||||
// 金额格式化:保留两位小数 + 千分位
|
// 金额格式化:保留两位小数 + 千分位
|
||||||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
@@ -470,6 +473,10 @@ export default function SocialInsurance() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-center gap-2 mb-3">
|
||||||
|
<Info className="w-4 h-4 shrink-0" />
|
||||||
|
<span>批量调整用于每年7月统一调基。如需单独调整某员工基数,请前往「花名册」→ 点击员工 → 编辑 → 修改「社保缴费基数」/「公积金缴费基数」。</span>
|
||||||
|
</div>
|
||||||
{isHousing ? (
|
{isHousing ? (
|
||||||
<>
|
<>
|
||||||
{(housingAllAccounts || []).length > 1 && (
|
{(housingAllAccounts || []).length > 1 && (
|
||||||
@@ -899,8 +906,20 @@ export default function SocialInsurance() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 基数说明(仅社保/公积金Tab显示) */}
|
||||||
|
{(tab === 'social' || tab === 'housing') && (
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
社保/公积金基数按上年度月均工资核定,每人不同,在员工基本信息中设置。比例和基数上下限按版本管理,通常每年7月调整。
|
||||||
|
发薪批次计算时按批次月份自动匹配对应版本配置。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ========== 月度办理 Tab ========== */}
|
{/* ========== 月度办理 Tab ========== */}
|
||||||
{tab === 'monthly' && (
|
{tab === 'monthly' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<PageGuide>
|
||||||
|
月度办理用于按月获取社保增减员名单并完成办理。流程:①选择月份点击「获取」→ ②系统自动汇总新增、减员、正常缴费人员 → ③确认无误后点击「完成办理」保存记录。办理完成后自动更新员工社保状态。
|
||||||
|
</PageGuide>
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="text-sm font-medium">月度办理</h2>
|
<h2 className="text-sm font-medium">月度办理</h2>
|
||||||
@@ -1121,6 +1140,7 @@ export default function SocialInsurance() {
|
|||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ========== 专项附加扣除 Tab ========== */}
|
{/* ========== 专项附加扣除 Tab ========== */}
|
||||||
@@ -1133,752 +1153,7 @@ export default function SocialInsurance() {
|
|||||||
<CommercialInsuranceTab />
|
<CommercialInsuranceTab />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
社保/公积金基数按上年度月均工资核定,每人不同,在员工基本信息中设置。比例和基数上下限按版本管理,通常每年7月调整。
|
|
||||||
发薪批次计算时按批次月份自动匹配对应版本配置。
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 月度办理社保行组件(可展开查看各险种明细,支持修改基数) */
|
|
||||||
function MonthlyRow({ item: i, type, onCorrected }: { item: any; type: 'add' | 'sub' | 'normal'; onCorrected?: () => void }) {
|
|
||||||
const [expanded, setExpanded] = useState(false)
|
|
||||||
const [editing, setEditing] = useState(false)
|
|
||||||
const [editBase, setEditBase] = useState(i.base?.toString() || '')
|
|
||||||
const typeLabel = type === 'add' ? (i.changeType === 'CITY_CHANGE' ? '新增(城市变更)' : '新增') : type === 'sub' ? (i.changeType === 'CITY_CHANGE' ? '减员(城市变更)' : '减员') : '正常'
|
|
||||||
const typeClass = type === 'add' ? 'bg-green-50 text-safe' : type === 'sub' ? 'bg-red-50 text-danger' : 'bg-gray-100 text-gray-500'
|
|
||||||
const d = i.detail
|
|
||||||
|
|
||||||
const correctMutation = useMutation({
|
|
||||||
mutationFn: (data: { base: number }) => socialInsuranceApi.correctRecord('social', i.recordId, data),
|
|
||||||
onSuccess: () => {
|
|
||||||
setEditing(false)
|
|
||||||
toast.success('基数已修改')
|
|
||||||
onCorrected?.()
|
|
||||||
},
|
|
||||||
onError: () => toast.error('修改失败'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleSaveBase = () => {
|
|
||||||
const val = Number(editBase) || 0
|
|
||||||
if (val <= 0) { toast.error('基数必须大于0'); return }
|
|
||||||
correctMutation.mutate({ base: val })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<tr className="border-b last:border-0 hover:bg-gray-50 cursor-pointer" onClick={() => setExpanded(!expanded)}>
|
|
||||||
<td className="py-1.5">{i.name} {d && <span className="text-gray-300 text-xs">{expanded ? '▾' : '▸'}</span>}</td>
|
|
||||||
<td className="py-1.5 text-gray-500">{i.department}</td>
|
|
||||||
<td className="py-1.5"><span className={`px-2 py-0.5 rounded text-xs ${typeClass}`}>{typeLabel}</span></td>
|
|
||||||
<td className="py-1.5 text-right">
|
|
||||||
{editing ? (
|
|
||||||
<span onClick={(e) => e.stopPropagation()} className="inline-flex items-center gap-1">
|
|
||||||
<Input type="number" step="0.01" min="0" className="!w-24 text-right text-xs" value={editBase}
|
|
||||||
onChange={(e) => setEditBase(e.target.value)} autoFocus />
|
|
||||||
<button className="text-xs text-primary hover:underline" onClick={handleSaveBase} disabled={correctMutation.isPending}>
|
|
||||||
{correctMutation.isPending ? '...' : '保存'}
|
|
||||||
</button>
|
|
||||||
<button className="text-xs text-gray-400 hover:underline" onClick={() => { setEditing(false); setEditBase(i.base?.toString() || '') }}>取消</button>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
¥{fmt(i.base)}
|
|
||||||
{i.recordId && type !== 'sub' && (
|
|
||||||
<button className="text-xs text-gray-400 hover:text-primary" onClick={(e) => { e.stopPropagation(); setEditing(true); setEditBase(i.base?.toString() || '') }}>
|
|
||||||
修改
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-1.5 text-right text-danger">{d ? `¥${fmt(d.totalOrg)}` : '-'}</td>
|
|
||||||
<td className="py-1.5 text-right text-warning">{d ? `¥${fmt(d.totalEmp)}` : '-'}</td>
|
|
||||||
<td className="py-1.5 text-right font-medium text-primary">{d ? `¥${fmt(d.total)}` : '-'}</td>
|
|
||||||
<td className="py-1.5 text-gray-400 text-xs">{type === 'add' ? `${i.startMonth} →` : type === 'sub' ? `→ ${i.endMonth}` : `${i.startMonth} ~ ${i.endMonth || '在保'}`}</td>
|
|
||||||
</tr>
|
|
||||||
{expanded && d && (
|
|
||||||
<tr className="bg-gray-50/50">
|
|
||||||
<td colSpan={8} className="py-2 px-8">
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b text-gray-400">
|
|
||||||
<th className="py-1 text-left">险种</th>
|
|
||||||
<th className="py-1 text-right">企业比例</th>
|
|
||||||
<th className="py-1 text-right">个人比例</th>
|
|
||||||
<th className="py-1 text-right">企业缴纳</th>
|
|
||||||
<th className="py-1 text-right">个人缴纳</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{d.items.map((item: any) => (
|
|
||||||
<tr key={item.name} className="border-b last:border-0">
|
|
||||||
<td className="py-1">{item.name}</td>
|
|
||||||
<td className="py-1 text-right text-gray-500">{item.orgRate}%</td>
|
|
||||||
<td className="py-1 text-right text-gray-500">{item.empRate > 0 ? `${item.empRate}%` : '-'}</td>
|
|
||||||
<td className="py-1 text-right">¥{fmt(item.orgAmount)}</td>
|
|
||||||
<td className="py-1 text-right">{item.empAmount > 0 ? `¥${fmt(item.empAmount)}` : '-'}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 月度办理公积金行组件(支持修改基数) */
|
|
||||||
function MonthlyHousingRow({ item: i, type, onCorrected }: { item: any; type: 'add' | 'sub' | 'normal'; onCorrected?: () => void }) {
|
|
||||||
const [editing, setEditing] = useState(false)
|
|
||||||
const [editBase, setEditBase] = useState(i.base?.toString() || '')
|
|
||||||
const typeLabel = type === 'add' ? (i.changeType === 'CITY_CHANGE' ? '新增(城市变更)' : '新增') : type === 'sub' ? (i.changeType === 'CITY_CHANGE' ? '减员(城市变更)' : '减员') : '正常'
|
|
||||||
const typeClass = type === 'add' ? 'bg-green-50 text-safe' : type === 'sub' ? 'bg-red-50 text-danger' : 'bg-gray-100 text-gray-500'
|
|
||||||
const d = i.detail
|
|
||||||
|
|
||||||
const correctMutation = useMutation({
|
|
||||||
mutationFn: (data: { base: number }) => socialInsuranceApi.correctRecord('housing', i.recordId, data),
|
|
||||||
onSuccess: () => {
|
|
||||||
setEditing(false)
|
|
||||||
toast.success('基数已修改')
|
|
||||||
onCorrected?.()
|
|
||||||
},
|
|
||||||
onError: () => toast.error('修改失败'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleSaveBase = () => {
|
|
||||||
const val = Number(editBase) || 0
|
|
||||||
if (val <= 0) { toast.error('基数必须大于0'); return }
|
|
||||||
correctMutation.mutate({ base: val })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr className="border-b last:border-0 hover:bg-gray-50">
|
|
||||||
<td className="py-1.5">{i.name}</td>
|
|
||||||
<td className="py-1.5 text-gray-500">{i.department}</td>
|
|
||||||
<td className="py-1.5"><span className={`px-2 py-0.5 rounded text-xs ${typeClass}`}>{typeLabel}</span></td>
|
|
||||||
<td className="py-1.5 text-right">
|
|
||||||
{editing ? (
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<Input type="number" step="0.01" min="0" className="!w-24 text-right text-xs" value={editBase}
|
|
||||||
onChange={(e) => setEditBase(e.target.value)} autoFocus />
|
|
||||||
<button className="text-xs text-primary hover:underline" onClick={handleSaveBase} disabled={correctMutation.isPending}>
|
|
||||||
{correctMutation.isPending ? '...' : '保存'}
|
|
||||||
</button>
|
|
||||||
<button className="text-xs text-gray-400 hover:underline" onClick={() => { setEditing(false); setEditBase(i.base?.toString() || '') }}>取消</button>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
¥{fmt(i.base)}
|
|
||||||
{i.recordId && type !== 'sub' && (
|
|
||||||
<button className="text-xs text-gray-400 hover:text-primary" onClick={() => { setEditing(true); setEditBase(i.base?.toString() || '') }}>
|
|
||||||
修改
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-1.5 text-right text-danger">{d ? `¥${fmt(d.orgAmount)}` : '-'}</td>
|
|
||||||
<td className="py-1.5 text-right text-warning">{d ? `¥${fmt(d.empAmount)}` : '-'}</td>
|
|
||||||
<td className="py-1.5 text-right font-medium text-primary">{d ? `¥${fmt(d.total)}` : '-'}</td>
|
|
||||||
<td className="py-1.5 text-gray-400 text-xs">{type === 'add' ? `${i.startMonth} →` : type === 'sub' ? `→ ${i.endMonth}` : `${i.startMonth} ~ ${i.endMonth || '在保'}`}</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 专项附加扣除按月录入组件 */
|
|
||||||
function SpecialDeductionTab({ month, setMonth }: { month: string; setMonth: (m: string) => void }) {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const [editing, setEditing] = useState<string | null>(null)
|
|
||||||
const [editForm, setEditForm] = useState<any>(null)
|
|
||||||
const [showImport, setShowImport] = useState(false)
|
|
||||||
const [importFile, setImportFile] = useState<File | null>(null)
|
|
||||||
const [importResult, setImportResult] = useState<any>(null)
|
|
||||||
const [importing, setImporting] = useState(false)
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
// 查询当月所有员工的专项附加扣除
|
|
||||||
const { data: records = [], isLoading } = useQuery<any[]>({
|
|
||||||
queryKey: ['special-deduction', month],
|
|
||||||
queryFn: async () => {
|
|
||||||
return await socialInsuranceApi.specialDeductionBatch(month)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// 查询当月社保在保人员(只有缴纳社保的员工才需要填报专项附加扣除)
|
|
||||||
const { data: employees = [] } = useQuery<any[]>({
|
|
||||||
queryKey: ['active-social-employees', month],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await socialInsuranceApi.activeDeclaration(month) as any
|
|
||||||
return (res?.items || []).map((item: any) => ({
|
|
||||||
id: item.employeeId,
|
|
||||||
name: item.name,
|
|
||||||
department: item.department,
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// 查询上月专项附加扣除数据(用于「复制上月」功能)
|
|
||||||
const prevMonth = (() => {
|
|
||||||
const [y, m] = month.split('-').map(Number)
|
|
||||||
const d = new Date(y, m - 2, 1)
|
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
||||||
})()
|
|
||||||
const { data: prevRecords = [] } = useQuery<any[]>({
|
|
||||||
queryKey: ['special-deduction', prevMonth],
|
|
||||||
queryFn: async () => {
|
|
||||||
return await socialInsuranceApi.specialDeductionBatch(prevMonth)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const prevRecordMap = new Map(prevRecords.map((r: any) => [r.employeeId, r]))
|
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
|
||||||
mutationFn: (data: any) => socialInsuranceApi.saveSpecialDeduction({ ...data, month }),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['special-deduction'] })
|
|
||||||
setEditing(null)
|
|
||||||
setEditForm(null)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const recordMap = new Map(records.map((r: any) => [r.employeeId, r]))
|
|
||||||
const unrecorded = employees.filter((e: any) => !recordMap.has(e.id))
|
|
||||||
|
|
||||||
const startEdit = (empId: string, existing?: any) => {
|
|
||||||
setEditing(empId)
|
|
||||||
setEditForm(existing ? {
|
|
||||||
children: existing.children,
|
|
||||||
elderly: existing.elderly,
|
|
||||||
housing: existing.housing,
|
|
||||||
education: existing.education,
|
|
||||||
infant: existing.infant,
|
|
||||||
remark: existing.remark,
|
|
||||||
} : { children: 0, elderly: 0, housing: 0, education: 0, infant: 0, remark: '' })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 批量复制上月数据到当月 */
|
|
||||||
const batchCopyMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
let copied = 0
|
|
||||||
for (const prev of prevRecords) {
|
|
||||||
await socialInsuranceApi.saveSpecialDeduction({
|
|
||||||
employeeId: prev.employeeId,
|
|
||||||
month,
|
|
||||||
children: prev.children || 0,
|
|
||||||
elderly: prev.elderly || 0,
|
|
||||||
housing: prev.housing || 0,
|
|
||||||
education: prev.education || 0,
|
|
||||||
infant: prev.infant || 0,
|
|
||||||
remark: prev.remark || '',
|
|
||||||
})
|
|
||||||
copied++
|
|
||||||
}
|
|
||||||
return copied
|
|
||||||
},
|
|
||||||
onSuccess: (copied: number) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['special-deduction'] })
|
|
||||||
if (copied > 0) {
|
|
||||||
toast.success(`已复制 ${copied} 条 ${prevMonth} 的扣除数据到 ${month}`)
|
|
||||||
} else {
|
|
||||||
toast.info(`${prevMonth} 无可复制的扣除数据`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: () => toast.error('复制上月数据失败'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const calcTotal = (f: any) => (f.children || 0) + (f.elderly || 0) + (f.housing || 0) + (f.education || 0) + (f.infant || 0)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h2 className="text-sm font-medium">专项附加扣除 — {month}</h2>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => batchCopyMutation.mutate()}
|
|
||||||
disabled={batchCopyMutation.isPending || prevRecords.length === 0}
|
|
||||||
>
|
|
||||||
{batchCopyMutation.isPending ? '复制中...' : `复制上月(${prevMonth})`}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setShowImport(true)}
|
|
||||||
>
|
|
||||||
<Upload className="w-3.5 h-3.5 mr-1" />批量导入
|
|
||||||
</Button>
|
|
||||||
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="!w-32" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center py-8 text-gray-500">加载中...</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* 已录入列表 */}
|
|
||||||
{records.length > 0 && (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b text-left text-xs text-gray-500">
|
|
||||||
<th className="py-2 font-medium">姓名</th>
|
|
||||||
<th className="py-2 font-medium">部门</th>
|
|
||||||
<th className="py-2 font-medium text-right">子女教育</th>
|
|
||||||
<th className="py-2 font-medium text-right">赡养老人</th>
|
|
||||||
<th className="py-2 font-medium text-right">住房</th>
|
|
||||||
<th className="py-2 font-medium text-right">继续教育</th>
|
|
||||||
<th className="py-2 font-medium text-right">婴幼儿照护</th>
|
|
||||||
<th className="py-2 font-medium text-right">合计</th>
|
|
||||||
<th className="py-2 font-medium">备注</th>
|
|
||||||
<th className="py-2"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{records.map((r: any) => (
|
|
||||||
<tr key={r.id} className="border-b last:border-0 hover:bg-gray-50">
|
|
||||||
{editing === r.employeeId ? (
|
|
||||||
<>
|
|
||||||
<td className="py-1.5">{r.employee?.name}</td>
|
|
||||||
<td className="py-1.5 text-gray-500">{r.employee?.department}</td>
|
|
||||||
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.children} onChange={(e) => setEditForm({ ...editForm, children: Number(e.target.value) })} /></td>
|
|
||||||
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.elderly} onChange={(e) => setEditForm({ ...editForm, elderly: Number(e.target.value) })} /></td>
|
|
||||||
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.housing} onChange={(e) => setEditForm({ ...editForm, housing: Number(e.target.value) })} /></td>
|
|
||||||
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.education} onChange={(e) => setEditForm({ ...editForm, education: Number(e.target.value) })} /></td>
|
|
||||||
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.infant} onChange={(e) => setEditForm({ ...editForm, infant: Number(e.target.value) })} /></td>
|
|
||||||
<td className="py-1.5 text-right font-medium text-primary">¥{fmt(calcTotal(editForm))}</td>
|
|
||||||
<td className="py-1"><Input className="!w-24 !h-8" value={editForm.remark || ''} onChange={(e) => setEditForm({ ...editForm, remark: e.target.value })} /></td>
|
|
||||||
<td className="py-1">
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<Button size="sm" className="!h-7 !px-2" onClick={() => saveMutation.mutate({ employeeId: r.employeeId, ...editForm })} disabled={saveMutation.isPending}>保存</Button>
|
|
||||||
<Button size="sm" variant="secondary" className="!h-7 !px-2" onClick={() => { setEditing(null); setEditForm(null) }}>取消</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<td className="py-1.5">{r.employee?.name}</td>
|
|
||||||
<td className="py-1.5 text-gray-500">{r.employee?.department}</td>
|
|
||||||
<td className="py-1.5 text-right">{r.children > 0 ? `¥${fmt(r.children)}` : '-'}</td>
|
|
||||||
<td className="py-1.5 text-right">{r.elderly > 0 ? `¥${fmt(r.elderly)}` : '-'}</td>
|
|
||||||
<td className="py-1.5 text-right">{r.housing > 0 ? `¥${fmt(r.housing)}` : '-'}</td>
|
|
||||||
<td className="py-1.5 text-right">{r.education > 0 ? `¥${fmt(r.education)}` : '-'}</td>
|
|
||||||
<td className="py-1.5 text-right">{r.infant > 0 ? `¥${fmt(r.infant)}` : '-'}</td>
|
|
||||||
<td className="py-1.5 text-right font-medium text-primary">¥{fmt(r.amount)}</td>
|
|
||||||
<td className="py-1.5 text-gray-400 text-xs">{r.remark || '-'}</td>
|
|
||||||
<td className="py-1.5"><button className="text-xs text-primary hover:underline" onClick={() => startEdit(r.employeeId, r)}>编辑</button></td>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 未录入员工 */}
|
|
||||||
{unrecorded.length > 0 && (
|
|
||||||
<div className="border-t pt-3">
|
|
||||||
<h3 className="text-xs font-medium text-gray-500 mb-2">未录入员工({unrecorded.length}人)</h3>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{unrecorded.map((e: any) => (
|
|
||||||
<button
|
|
||||||
key={e.id}
|
|
||||||
className="px-2 py-1 rounded-md border border-gray-200 text-xs text-gray-600 hover:border-primary hover:text-primary"
|
|
||||||
onClick={() => startEdit(e.id)}
|
|
||||||
>
|
|
||||||
{e.name}({e.department})
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 新增/编辑表单 */}
|
|
||||||
{editing && !recordMap.has(editing) && (
|
|
||||||
<div className="border rounded-md p-3 bg-gray-50 space-y-2">
|
|
||||||
<h3 className="text-xs font-medium">新增专项附加扣除 — {employees.find((e: any) => e.id === editing)?.name}</h3>
|
|
||||||
<div className="grid grid-cols-5 gap-2">
|
|
||||||
<div><Label>子女教育</Label><Input type="number" value={editForm.children} onChange={(e) => setEditForm({ ...editForm, children: Number(e.target.value) })} /></div>
|
|
||||||
<div><Label>赡养老人</Label><Input type="number" value={editForm.elderly} onChange={(e) => setEditForm({ ...editForm, elderly: Number(e.target.value) })} /></div>
|
|
||||||
<div><Label>住房</Label><Input type="number" value={editForm.housing} onChange={(e) => setEditForm({ ...editForm, housing: Number(e.target.value) })} /></div>
|
|
||||||
<div><Label>继续教育</Label><Input type="number" value={editForm.education} onChange={(e) => setEditForm({ ...editForm, education: Number(e.target.value) })} /></div>
|
|
||||||
<div><Label>婴幼儿照护</Label><Input type="number" value={editForm.infant} onChange={(e) => setEditForm({ ...editForm, infant: Number(e.target.value) })} /></div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex-1"><Label>备注</Label><Input value={editForm.remark} onChange={(e) => setEditForm({ ...editForm, remark: e.target.value })} /></div>
|
|
||||||
<div className="text-sm text-gray-500 pt-5">合计:<span className="font-medium text-primary">¥{fmt(calcTotal(editForm))}</span></div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button size="sm" onClick={() => saveMutation.mutate({ employeeId: editing, ...editForm })} disabled={saveMutation.isPending}>保存</Button>
|
|
||||||
<Button size="sm" variant="secondary" onClick={() => { setEditing(null); setEditForm(null) }}>取消</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{records.length === 0 && unrecorded.length === 0 && (
|
|
||||||
<div className="text-center py-8 text-gray-400 text-sm">暂无员工数据</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 批量导入弹窗 */}
|
|
||||||
{showImport && (
|
|
||||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setShowImport(false)}>
|
|
||||||
<Card className="max-w-lg w-full" >
|
|
||||||
<div onClick={(e) => e.stopPropagation()} className="p-4">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h2 className="text-sm font-medium">批量导入专项附加扣除 — {month}</h2>
|
|
||||||
<button onClick={() => { setShowImport(false); setImportFile(null); setImportResult(null) }} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button size="sm" variant="secondary" onClick={async () => {
|
|
||||||
try {
|
|
||||||
const token = useAuthStore.getState().accessToken
|
|
||||||
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
|
|
||||||
const res = await fetch(`${baseURL}/import/special-deduction/template`, {
|
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
||||||
})
|
|
||||||
const blob = await res.blob()
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = '专项附加扣除导入模板.xlsx'
|
|
||||||
a.click()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
} catch { toast.error('下载模板失败') }
|
|
||||||
}}>
|
|
||||||
<Download className="w-3.5 h-3.5 mr-1" />下载模板
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
|
|
||||||
<input ref={fileInputRef} type="file" accept=".xlsx,.xls" className="hidden" id="special-deduction-import-file" onChange={(e) => { setImportFile(e.target.files?.[0] || null); setImportResult(null) }} />
|
|
||||||
<label htmlFor="special-deduction-import-file" className="cursor-pointer text-xs text-primary hover:underline">
|
|
||||||
{importFile ? importFile.name : '点击选择 Excel 文件'}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{importResult && (
|
|
||||||
<div className="px-3 py-2 rounded-md bg-green-50 text-green-700 text-xs space-y-1">
|
|
||||||
<div className="font-medium">导入完成</div>
|
|
||||||
<div>成功 {importResult.updated} 条,跳过 {importResult.skipped} 条,共 {importResult.total} 条</div>
|
|
||||||
{importResult.errors?.length > 0 && (
|
|
||||||
<div className="mt-1 pt-1 border-t border-green-200">
|
|
||||||
{importResult.errors.slice(0, 5).map((e: string, i: number) => <div key={i} className="text-amber-600">{e}</div>)}
|
|
||||||
{importResult.errors.length > 5 && <div className="text-amber-600">...还有 {importResult.errors.length - 5} 条</div>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="secondary" size="sm" onClick={() => { setShowImport(false); setImportFile(null); setImportResult(null) }}>取消</Button>
|
|
||||||
<Button size="sm" onClick={async () => {
|
|
||||||
if (!importFile) return toast.error('请选择文件')
|
|
||||||
setImporting(true)
|
|
||||||
setImportResult(null)
|
|
||||||
try {
|
|
||||||
const token = useAuthStore.getState().accessToken
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('file', importFile)
|
|
||||||
formData.append('month', month)
|
|
||||||
const res = await fetch('/api/v1/import/special-deduction', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
||||||
body: formData,
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
if (!data.success) { toast.error(data.error?.message || '导入失败') }
|
|
||||||
else {
|
|
||||||
setImportResult(data.data)
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['special-deduction'] })
|
|
||||||
toast.success(`导入完成:成功 ${data.data.updated} 条`)
|
|
||||||
}
|
|
||||||
} catch (e: any) { toast.error(e?.message || '导入失败') }
|
|
||||||
finally { setImporting(false) }
|
|
||||||
}} disabled={!importFile || importing}>{importing ? '导入中...' : '开始导入'}</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 商险管理 Tab — 管理商业保险(意外险、补充医疗、雇主责任险等)
|
|
||||||
* 支持查看商险方案、参保人员、保单信息
|
|
||||||
*/
|
|
||||||
function CommercialInsuranceTab() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const confirm = useConfirm()
|
|
||||||
const [showAddPlan, setShowAddPlan] = useState(false)
|
|
||||||
const [editingPlan, setEditingPlan] = useState<any>(null)
|
|
||||||
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null)
|
|
||||||
const [newPlan, setNewPlan] = useState<any>({
|
|
||||||
name: '',
|
|
||||||
type: 'ACCIDENT',
|
|
||||||
provider: '',
|
|
||||||
policyNo: '',
|
|
||||||
premium: 0,
|
|
||||||
coverageAmount: 0,
|
|
||||||
effectiveFrom: new Date().toISOString().slice(0, 10),
|
|
||||||
effectiveTo: '',
|
|
||||||
description: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 商险类型映射 */
|
|
||||||
const INSURANCE_TYPES: Record<string, { label: string; color: string }> = {
|
|
||||||
ACCIDENT: { label: '意外伤害险', color: 'bg-orange-50 text-orange-700 border border-orange-200' },
|
|
||||||
SUPPLEMENTARY_MEDICAL: { label: '补充医疗保险', color: 'bg-blue-50 text-blue-700 border border-blue-200' },
|
|
||||||
EMPLOYER_LIABILITY: { label: '雇主责任险', color: 'bg-purple-50 text-purple-700 border border-purple-200' },
|
|
||||||
CRITICAL_ILLNESS: { label: '重大疾病险', color: 'bg-rose-50 text-rose-700 border border-rose-200' },
|
|
||||||
GROUP_LIFE: { label: '团体寿险', color: 'bg-teal-50 text-teal-700 border border-teal-200' },
|
|
||||||
OTHER: { label: '其他', color: 'bg-gray-50 text-gray-700 border border-gray-200' },
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取商险方案列表 */
|
|
||||||
const { data: plans = [], isLoading } = useQuery<any[]>({
|
|
||||||
queryKey: ['commercial-insurance-plans'],
|
|
||||||
queryFn: async () => {
|
|
||||||
return await commercialInsuranceApi.plans()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 获取选中方案的参保人员 */
|
|
||||||
const { data: enrollments = [], isLoading: enrollLoading } = useQuery<any[]>({
|
|
||||||
queryKey: ['commercial-insurance-enrollments', selectedPlanId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!selectedPlanId) return []
|
|
||||||
return await commercialInsuranceApi.enrollments(selectedPlanId)
|
|
||||||
},
|
|
||||||
enabled: !!selectedPlanId,
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 创建/更新商险方案 */
|
|
||||||
const savePlanMutation = useMutation({
|
|
||||||
mutationFn: async (data: any) => {
|
|
||||||
if (editingPlan) {
|
|
||||||
return commercialInsuranceApi.savePlan(data, editingPlan.id) as any
|
|
||||||
}
|
|
||||||
return commercialInsuranceApi.savePlan(data) as any
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['commercial-insurance-plans'] })
|
|
||||||
setShowAddPlan(false)
|
|
||||||
setEditingPlan(null)
|
|
||||||
setNewPlan({ name: '', type: 'ACCIDENT', provider: '', policyNo: '', premium: 0, coverageAmount: 0, effectiveFrom: new Date().toISOString().slice(0, 10), effectiveTo: '', description: '' })
|
|
||||||
toast.success(editingPlan ? '商险方案已更新' : '商险方案已创建')
|
|
||||||
},
|
|
||||||
onError: () => toast.error('保存失败'),
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 删除商险方案 */
|
|
||||||
const deletePlanMutation = useMutation({
|
|
||||||
mutationFn: (id: string) => commercialInsuranceApi.removePlan(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['commercial-insurance-plans'] })
|
|
||||||
setSelectedPlanId(null)
|
|
||||||
toast.success('商险方案已删除')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleEdit = (plan: any) => {
|
|
||||||
setEditingPlan(plan)
|
|
||||||
setNewPlan({ ...plan })
|
|
||||||
setShowAddPlan(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
if (!newPlan.name?.trim()) { toast.error('请填写方案名称'); return }
|
|
||||||
if (!newPlan.provider?.trim()) { toast.error('请填写保险公司'); return }
|
|
||||||
savePlanMutation.mutate(newPlan)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) return <Card><div className="text-center py-8 text-gray-400">加载中...</div></Card>
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Shield className="h-4 w-4 text-primary" />
|
|
||||||
<h2 className="text-sm font-medium">商业保险管理</h2>
|
|
||||||
</div>
|
|
||||||
<Button size="sm" onClick={() => { setEditingPlan(null); setNewPlan({ name: '', type: 'ACCIDENT', provider: '', policyNo: '', premium: 0, coverageAmount: 0, effectiveFrom: new Date().toISOString().slice(0, 10), effectiveTo: '', description: '' }); setShowAddPlan(true) }}>
|
|
||||||
<Plus className="w-4 h-4 mr-1" />新增方案
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<InlineAlert type="info">
|
|
||||||
商业保险为社保之外的自愿性补充保障,包括意外伤害险、补充医疗、雇主责任险等。此处管理商险方案及参保人员。
|
|
||||||
</InlineAlert>
|
|
||||||
|
|
||||||
{/* 商险方案列表 */}
|
|
||||||
{plans.length === 0 ? (
|
|
||||||
<Card><div className="text-center py-8 text-gray-400 text-sm">暂无商险方案,点击「新增方案」创建</div></Card>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{plans.map((plan: any) => {
|
|
||||||
const typeCfg = INSURANCE_TYPES[plan.type] || INSURANCE_TYPES.OTHER
|
|
||||||
const isSelected = selectedPlanId === plan.id
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={plan.id}
|
|
||||||
className={`cursor-pointer transition-all ${isSelected ? 'ring-2 ring-primary/20' : 'hover:shadow-md'}`}
|
|
||||||
>
|
|
||||||
<div onClick={() => setSelectedPlanId(isSelected ? null : plan.id)}>
|
|
||||||
<div className="flex items-start justify-between mb-2">
|
|
||||||
<div>
|
|
||||||
<span className={`px-2 py-0.5 rounded text-xs ${typeCfg.color}`}>{typeCfg.label}</span>
|
|
||||||
<h3 className="text-sm font-medium mt-1">{plan.name}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<button className="text-xs text-gray-400 hover:text-primary" onClick={() => handleEdit(plan)}>
|
|
||||||
<SettingsIcon className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
<button className="text-xs text-gray-400 hover:text-danger" onClick={async () => {
|
|
||||||
if (await confirm({ title: '确认删除', message: `确定删除商险方案「${plan.name}」吗?` })) {
|
|
||||||
deletePlanMutation.mutate(plan.id)
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<X className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1 text-xs text-gray-500">
|
|
||||||
<div className="flex justify-between"><span>保险公司</span><span className="text-gray-700">{plan.provider}</span></div>
|
|
||||||
<div className="flex justify-between"><span>保单号</span><span className="text-gray-700 font-mono">{plan.policyNo || '—'}</span></div>
|
|
||||||
<div className="flex justify-between"><span>保费(年)</span><span className="text-gray-700">¥{fmt(plan.premium)}</span></div>
|
|
||||||
<div className="flex justify-between"><span>保额</span><span className="text-gray-700">¥{fmt(plan.coverageAmount)}</span></div>
|
|
||||||
<div className="flex justify-between"><span>保障期间</span><span className="text-gray-700">{plan.effectiveFrom} ~ {plan.effectiveTo || '长期'}</span></div>
|
|
||||||
</div>
|
|
||||||
{plan.description && <p className="text-xs text-gray-400 mt-2 line-clamp-2">{plan.description}</p>}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 参保人员列表 */}
|
|
||||||
{selectedPlanId && (
|
|
||||||
<Card>
|
|
||||||
<h3 className="text-sm font-medium mb-3">参保人员({enrollments.length}人)</h3>
|
|
||||||
{enrollLoading ? (
|
|
||||||
<div className="text-center py-4 text-gray-400 text-sm">加载中...</div>
|
|
||||||
) : enrollments.length === 0 ? (
|
|
||||||
<div className="text-center py-4 text-gray-400 text-sm">暂无参保人员</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b text-xs text-gray-500">
|
|
||||||
<th className="py-2 text-left">姓名</th>
|
|
||||||
<th className="py-2 text-left">部门</th>
|
|
||||||
<th className="py-2 text-left">身份证号</th>
|
|
||||||
<th className="py-2 text-right">保费</th>
|
|
||||||
<th className="py-2 text-left">生效日期</th>
|
|
||||||
<th className="py-2 text-left">状态</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{enrollments.map((e: any) => (
|
|
||||||
<tr key={e.id || e.employeeId} className="border-b last:border-0 hover:bg-gray-50">
|
|
||||||
<td className="py-2 font-medium">{e.name}</td>
|
|
||||||
<td className="py-2 text-gray-500">{e.department}</td>
|
|
||||||
<td className="py-2 text-gray-400 font-mono text-xs">{e.idCardMasked || '—'}</td>
|
|
||||||
<td className="py-2 text-right">¥{fmt(e.premium || 0)}</td>
|
|
||||||
<td className="py-2 text-gray-500 text-xs">{e.effectiveFrom || '—'}</td>
|
|
||||||
<td className="py-2">
|
|
||||||
<span className={`px-2 py-0.5 rounded text-xs ${e.status === 'ACTIVE' ? 'bg-green-50 text-safe' : 'bg-gray-100 text-gray-500'}`}>
|
|
||||||
{e.status === 'ACTIVE' ? '有效' : '已终止'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 新增/编辑方案弹窗 */}
|
|
||||||
{showAddPlan && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={() => setShowAddPlan(false)}>
|
|
||||||
<Card className="w-full max-w-lg mx-4" >
|
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-sm font-medium">{editingPlan ? '编辑商险方案' : '新增商险方案'}</h3>
|
|
||||||
<button onClick={() => setShowAddPlan(false)} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<Label>方案名称 *</Label>
|
|
||||||
<Input value={newPlan.name} onChange={(e) => setNewPlan({ ...newPlan, name: e.target.value })} placeholder="如:2024年度员工意外险" />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<Label>保险类型</Label>
|
|
||||||
<select
|
|
||||||
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
|
|
||||||
value={newPlan.type}
|
|
||||||
onChange={(e) => setNewPlan({ ...newPlan, type: e.target.value })}
|
|
||||||
>
|
|
||||||
{Object.entries(INSURANCE_TYPES).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>保险公司 *</Label>
|
|
||||||
<Input value={newPlan.provider} onChange={(e) => setNewPlan({ ...newPlan, provider: e.target.value })} placeholder="如:中国人寿" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<Label>保单号</Label>
|
|
||||||
<Input value={newPlan.policyNo} onChange={(e) => setNewPlan({ ...newPlan, policyNo: e.target.value })} placeholder="保单编号" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>保费(元/年)</Label>
|
|
||||||
<Input type="number" value={newPlan.premium} onChange={(e) => setNewPlan({ ...newPlan, premium: parseFloat(e.target.value) || 0 })} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<Label>保额(元)</Label>
|
|
||||||
<Input type="number" value={newPlan.coverageAmount} onChange={(e) => setNewPlan({ ...newPlan, coverageAmount: parseFloat(e.target.value) || 0 })} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>到期日期</Label>
|
|
||||||
<Input type="date" value={newPlan.effectiveTo} onChange={(e) => setNewPlan({ ...newPlan, effectiveTo: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>生效日期</Label>
|
|
||||||
<Input type="date" value={newPlan.effectiveFrom} onChange={(e) => setNewPlan({ ...newPlan, effectiveFrom: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>备注</Label>
|
|
||||||
<Input value={newPlan.description} onChange={(e) => setNewPlan({ ...newPlan, description: e.target.value })} placeholder="保障范围、免赔额等" />
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
|
||||||
<Button variant="secondary" size="sm" onClick={() => setShowAddPlan(false)}>取消</Button>
|
|
||||||
<Button size="sm" onClick={handleSave} disabled={savePlanMutation.isPending}>
|
|
||||||
{savePlanMutation.isPending ? '保存中...' : '保存'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,9 +4,12 @@
|
|||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Search, Plus, Edit2, Trash2, AlertTriangle, Clock, Baby, HeartPulse, Activity, X } from 'lucide-react'
|
import { Search, Plus, Edit2, Trash2, AlertTriangle, Clock, Baby, HeartPulse, Activity, X } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
import { specialStatusApi, employeeApi } from '../lib/api-services'
|
import { specialStatusApi, employeeApi } from '../lib/api-services'
|
||||||
import { Input, Select, Label } from '../components/ui/Input'
|
import { Input, Select, Label } from '../components/ui/Input'
|
||||||
import Button from '../components/ui/Button'
|
import Button from '../components/ui/Button'
|
||||||
|
import PageGuide from '../components/ui/PageGuide'
|
||||||
|
import QueryError from '../components/ui/QueryError'
|
||||||
|
|
||||||
interface SpecialStatus {
|
interface SpecialStatus {
|
||||||
id: string
|
id: string
|
||||||
@@ -105,6 +108,7 @@ export default function SpecialStatus() {
|
|||||||
const [typeFilter, setTypeFilter] = useState('')
|
const [typeFilter, setTypeFilter] = useState('')
|
||||||
const [statusFilter, setStatusFilter] = useState('')
|
const [statusFilter, setStatusFilter] = useState('')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [fetchError, setFetchError] = useState<any>(null)
|
||||||
const [stats, setStats] = useState<Stats | null>(null)
|
const [stats, setStats] = useState<Stats | null>(null)
|
||||||
const [editOpen, setEditOpen] = useState(false)
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState<SpecialStatus | null>(null)
|
const [editing, setEditing] = useState<SpecialStatus | null>(null)
|
||||||
@@ -135,6 +139,7 @@ export default function SpecialStatus() {
|
|||||||
|
|
||||||
const fetchList = async () => {
|
const fetchList = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
setFetchError(null)
|
||||||
try {
|
try {
|
||||||
const params: any = { page, pageSize }
|
const params: any = { page, pageSize }
|
||||||
if (search) params.search = search
|
if (search) params.search = search
|
||||||
@@ -143,6 +148,8 @@ export default function SpecialStatus() {
|
|||||||
const res = await specialStatusApi.list({ page, pageSize, search: search || undefined, type: typeFilter || undefined, status: statusFilter || undefined } as any) as any
|
const res = await specialStatusApi.list({ page, pageSize, search: search || undefined, type: typeFilter || undefined, status: statusFilter || undefined } as any) as any
|
||||||
setList(res.list)
|
setList(res.list)
|
||||||
setTotal(res.total)
|
setTotal(res.total)
|
||||||
|
} catch (err: any) {
|
||||||
|
setFetchError(err)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -201,7 +208,7 @@ export default function SpecialStatus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!form.employeeId) { alert('请选择员工'); return }
|
if (!form.employeeId) { toast.error('请选择员工'); return }
|
||||||
try {
|
try {
|
||||||
const data: any = { ...form }
|
const data: any = { ...form }
|
||||||
// 空字符串转 null
|
// 空字符串转 null
|
||||||
@@ -220,7 +227,7 @@ export default function SpecialStatus() {
|
|||||||
fetchList()
|
fetchList()
|
||||||
fetchStats()
|
fetchStats()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.response?.data?.error?.message || '保存失败')
|
toast.error(err.response?.data?.error?.message || '保存失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +239,7 @@ export default function SpecialStatus() {
|
|||||||
fetchList()
|
fetchList()
|
||||||
fetchStats()
|
fetchStats()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.response?.data?.error?.message || '删除失败')
|
toast.error(err.response?.data?.error?.message || '删除失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +247,9 @@ export default function SpecialStatus() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<PageGuide>
|
||||||
|
特殊状态台账用于跟踪三期(孕期/产期/哺乳期)、工伤、医疗期等特殊员工状态。系统自动计算状态剩余天数并发送到期提醒。可按类型、状态筛选查看,及时关注即将到期的特殊员工。
|
||||||
|
</PageGuide>
|
||||||
{/* 标题 */}
|
{/* 标题 */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">特殊状态台账</h1>
|
<h1 className="text-2xl font-bold text-gray-900">特殊状态台账</h1>
|
||||||
@@ -312,6 +322,8 @@ export default function SpecialStatus() {
|
|||||||
{/* 列表 */}
|
{/* 列表 */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-12 text-gray-400">加载中...</div>
|
<div className="text-center py-12 text-gray-400">加载中...</div>
|
||||||
|
) : fetchError ? (
|
||||||
|
<QueryError error={fetchError} onRetry={fetchList} />
|
||||||
) : list.length === 0 ? (
|
) : list.length === 0 ? (
|
||||||
<div className="text-center py-12 text-gray-400">
|
<div className="text-center py-12 text-gray-400">
|
||||||
<AlertTriangle className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
<AlertTriangle className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { FileText, Copy, X, ChevronRight, Download, BookOpen, HelpCircle, Plus, Edit, Trash2, Building2 } from 'lucide-react'
|
import { FileText, Copy, X, Download, BookOpen, HelpCircle, Plus, Edit, Trash2, Building2 } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { templatesApi } from '../lib/api-services'
|
import { templatesApi } from '../lib/api-services'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
@@ -10,6 +10,7 @@ import { Input, Label, Select } from '../components/ui/Input'
|
|||||||
import Modal from '../components/ui/Modal'
|
import Modal from '../components/ui/Modal'
|
||||||
import EmptyState from '../components/ui/EmptyState'
|
import EmptyState from '../components/ui/EmptyState'
|
||||||
import Pagination from '../components/ui/Pagination'
|
import Pagination from '../components/ui/Pagination'
|
||||||
|
import { useConfirm } from '../hooks/useConfirm'
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<string, string> = {
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
CONTRACT: '合同',
|
CONTRACT: '合同',
|
||||||
@@ -300,6 +301,7 @@ function SystemTemplates() {
|
|||||||
|
|
||||||
function EnterpriseTemplates() {
|
function EnterpriseTemplates() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const confirm = useConfirm()
|
||||||
const [category, setCategory] = useState<string>('')
|
const [category, setCategory] = useState<string>('')
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [pageSize, setPageSize] = useState(20)
|
const [pageSize, setPageSize] = useState(20)
|
||||||
@@ -448,7 +450,7 @@ function EnterpriseTemplates() {
|
|||||||
<Edit className="w-3 h-3" />编辑
|
<Edit className="w-3 h-3" />编辑
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (confirm('确认删除?')) deleteMutation.mutate(t.id) }}
|
onClick={async () => { if (await confirm({ title: '删除模板', message: '确认删除?' })) deleteMutation.mutate(t.id) }}
|
||||||
className="flex items-center gap-1 text-xs text-gray-500 hover:text-red-600"
|
className="flex items-center gap-1 text-xs text-gray-500 hover:text-red-600"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />删除
|
<Trash2 className="w-3 h-3" />删除
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
|
|||||||
import { AlertTriangle, Check, ChevronRight, ChevronLeft, Shield, Info, Calculator, FileText, Printer, Trash2, List, Download, Plus, Edit, Send, CheckCircle, XCircle, Play, Ban, CheckCheck } from 'lucide-react'
|
import { AlertTriangle, Check, ChevronRight, ChevronLeft, Shield, Info, Calculator, FileText, Printer, Trash2, List, Download, Plus, Edit, Send, CheckCircle, XCircle, Play, Ban, CheckCheck } from 'lucide-react'
|
||||||
import { Stepper } from '../components/ui/Stepper'
|
import { Stepper } from '../components/ui/Stepper'
|
||||||
import { InlineAlert } from '../components/ui/InlineAlert'
|
import { InlineAlert } from '../components/ui/InlineAlert'
|
||||||
|
import PageGuide from '../components/ui/PageGuide'
|
||||||
import jsPDF from 'jspdf'
|
import jsPDF from 'jspdf'
|
||||||
import { rosterApi, terminationApi } from '../lib/api-services'
|
import { rosterApi, terminationApi } from '../lib/api-services'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
@@ -12,6 +13,7 @@ import Button from '../components/ui/Button'
|
|||||||
import { Input, Label, Select } from '../components/ui/Input'
|
import { Input, Label, Select } from '../components/ui/Input'
|
||||||
import EmptyState from '../components/ui/EmptyState'
|
import EmptyState from '../components/ui/EmptyState'
|
||||||
import Pagination from '../components/ui/Pagination'
|
import Pagination from '../components/ui/Pagination'
|
||||||
|
import { useConfirm } from '../hooks/useConfirm'
|
||||||
|
|
||||||
// 金额格式化:保留两位小数 + 千分位
|
// 金额格式化:保留两位小数 + 千分位
|
||||||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
@@ -139,19 +141,6 @@ const DEFAULT_HANDOVER_ITEMS = [
|
|||||||
{ key: 'contract_return', label: '劳动合同收回', done: false, remark: '' },
|
{ key: 'contract_return', label: '劳动合同收回', done: false, remark: '' },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface RosterEmployee {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
department: string
|
|
||||||
status: string
|
|
||||||
hasTermination?: boolean
|
|
||||||
latestTerminationStatus?: string
|
|
||||||
hireDate: string
|
|
||||||
monthlySalary: number
|
|
||||||
latestContract: any
|
|
||||||
counts: any
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EmployeeProfile {
|
interface EmployeeProfile {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -171,6 +160,7 @@ interface EmployeeProfile {
|
|||||||
|
|
||||||
export default function Termination() {
|
export default function Termination() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const confirm = useConfirm()
|
||||||
const [view, setView] = useState<'list' | 'wizard' | 'detail'>('list')
|
const [view, setView] = useState<'list' | 'wizard' | 'detail'>('list')
|
||||||
const [draftId, setDraftId] = useState<string | null>(null)
|
const [draftId, setDraftId] = useState<string | null>(null)
|
||||||
const [step, setStep] = useState(0)
|
const [step, setStep] = useState(0)
|
||||||
@@ -182,7 +172,7 @@ export default function Termination() {
|
|||||||
const [checklist, setChecklist] = useState<Record<string, boolean>>({})
|
const [checklist, setChecklist] = useState<Record<string, boolean>>({})
|
||||||
const [acknowledgeRisk, setAcknowledgeRisk] = useState(false)
|
const [acknowledgeRisk, setAcknowledgeRisk] = useState(false)
|
||||||
const [socialAvgWage, setSocialAvgWage] = useState(0)
|
const [socialAvgWage, setSocialAvgWage] = useState(0)
|
||||||
const [compBreakdown, setCompBreakdown] = useState<any>(null)
|
const [, setCompBreakdown] = useState<any>(null)
|
||||||
const [compAdjustments, setCompAdjustments] = useState<Array<{ field: string; from: number; to: number; reason: string }>>([])
|
const [compAdjustments, setCompAdjustments] = useState<Array<{ field: string; from: number; to: number; reason: string }>>([])
|
||||||
const [handoverItems, setHandoverItems] = useState(DEFAULT_HANDOVER_ITEMS)
|
const [handoverItems, setHandoverItems] = useState(DEFAULT_HANDOVER_ITEMS)
|
||||||
const [checklistOverrides, setChecklistOverrides] = useState<Record<string, { checked: boolean; overrideReason: string }>>({})
|
const [checklistOverrides, setChecklistOverrides] = useState<Record<string, { checked: boolean; overrideReason: string }>>({})
|
||||||
@@ -341,7 +331,7 @@ export default function Termination() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 草稿列表
|
// 草稿列表
|
||||||
const { data: draftsData, refetch: refetchDrafts } = useQuery({
|
const { data: draftsData } = useQuery({
|
||||||
queryKey: ['termination-drafts', filterStatus, filterDepartment, searchTerm, draftPage, draftPageSize],
|
queryKey: ['termination-drafts', filterStatus, filterDepartment, searchTerm, draftPage, draftPageSize],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params: any = { page: draftPage, pageSize: draftPageSize }
|
const params: any = { page: draftPage, pageSize: draftPageSize }
|
||||||
@@ -524,26 +514,35 @@ export default function Termination() {
|
|||||||
}
|
}
|
||||||
}, [selectedEmployee, terminationDate, socialAvgWage, reason])
|
}, [selectedEmployee, terminationDate, socialAvgWage, reason])
|
||||||
|
|
||||||
|
// 计算调整后的实际补偿金(系统预估 + 手动调整差额)
|
||||||
|
const adjustedTotal = useMemo(() => {
|
||||||
|
if (!costResult) return 0
|
||||||
|
let total = costResult.grandTotal
|
||||||
|
compAdjustments.forEach(adj => {
|
||||||
|
total += (adj.to - adj.from)
|
||||||
|
})
|
||||||
|
return total
|
||||||
|
}, [costResult, compAdjustments])
|
||||||
|
|
||||||
const canProceed = () => {
|
const canProceed = () => {
|
||||||
if (step === 0) return !!employeeId && (!!draftId || !selectedEmployee?.hasTermination || selectedEmployee?.latestTerminationStatus === 'CANCELLED' || selectedEmployee?.latestTerminationStatus === 'COMPLETED')
|
if (step === 0) return !!employeeId && (!!draftId || !selectedEmployee?.hasTermination || selectedEmployee?.latestTerminationStatus === 'CANCELLED' || selectedEmployee?.latestTerminationStatus === 'COMPLETED')
|
||||||
if (step === 1) return !!reason && !!terminationDate && (!riskAssessment?.warnings.length || acknowledgeRisk)
|
if (step === 1) return !!reason && !!terminationDate && (!riskAssessment?.warnings.length || acknowledgeRisk)
|
||||||
if (step === 2) return true
|
// step 2: 合规检查 — required 项必须勾选
|
||||||
if (step === 3) return true
|
if (step === 2) {
|
||||||
if (step === 4) return true
|
if (!checklistItems) return true
|
||||||
return false
|
const requiredItems = checklistItems.filter(item => item.suggestionType === 'required')
|
||||||
|
if (requiredItems.length === 0) return true
|
||||||
|
return requiredItems.every(item => checklist[item.key] === true)
|
||||||
}
|
}
|
||||||
|
// step 3: 费用结算 — 有补偿金时需确认
|
||||||
const handleSave = () => {
|
if (step === 3) return true
|
||||||
saveMutation.mutate({
|
// step 4: 工作交接 — 关键项需完成
|
||||||
employeeId,
|
if (step === 4) {
|
||||||
reason,
|
const keyItems = handoverItems.filter(item => item.key === 'work_handover' || item.key === 'equipment_return' || item.key === 'access_revoke')
|
||||||
terminationDate: new Date(terminationDate).toISOString(),
|
if (keyItems.length === 0) return true
|
||||||
socialInsEndMonth: socialInsEndMonth || terminationDate.slice(0, 7),
|
return keyItems.every(item => item.done)
|
||||||
housingFundEndMonth: housingFundEndMonth || terminationDate.slice(0, 7),
|
}
|
||||||
compensation: costResult?.totalSeverance || 0,
|
return false
|
||||||
checklist,
|
|
||||||
remark: '',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 保存草稿(任意步骤可调用) */
|
/** 保存草稿(任意步骤可调用) */
|
||||||
@@ -553,7 +552,8 @@ export default function Termination() {
|
|||||||
noticePay: costResult.noticePay,
|
noticePay: costResult.noticePay,
|
||||||
doublePay: costResult.doublePay,
|
doublePay: costResult.doublePay,
|
||||||
other: 0,
|
other: 0,
|
||||||
total: costResult.grandTotal,
|
total: adjustedTotal,
|
||||||
|
systemTotal: costResult.grandTotal,
|
||||||
adjustments: compAdjustments,
|
adjustments: compAdjustments,
|
||||||
} : null
|
} : null
|
||||||
|
|
||||||
@@ -564,7 +564,7 @@ export default function Termination() {
|
|||||||
terminationDate: terminationDate ? new Date(terminationDate).toISOString() : new Date().toISOString(),
|
terminationDate: terminationDate ? new Date(terminationDate).toISOString() : new Date().toISOString(),
|
||||||
socialInsEndMonth: socialInsEndMonth || terminationDate.slice(0, 7),
|
socialInsEndMonth: socialInsEndMonth || terminationDate.slice(0, 7),
|
||||||
housingFundEndMonth: housingFundEndMonth || terminationDate.slice(0, 7),
|
housingFundEndMonth: housingFundEndMonth || terminationDate.slice(0, 7),
|
||||||
compensation: costResult?.grandTotal || 0,
|
compensation: adjustedTotal || 0,
|
||||||
checklist,
|
checklist,
|
||||||
currentStep: step,
|
currentStep: step,
|
||||||
compensationBreakdown: breakdown,
|
compensationBreakdown: breakdown,
|
||||||
@@ -610,41 +610,6 @@ export default function Termination() {
|
|||||||
toast.success('已调整')
|
toast.success('已调整')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模拟计算:追加新版本,支持参数对比
|
|
||||||
const handleSimulate = () => {
|
|
||||||
if (!costResult || !selectedEmployee) return
|
|
||||||
setSavedItems((prev) => {
|
|
||||||
// 该员工的最新版本号
|
|
||||||
const sameEmployee = prev.filter(item => item.employeeId === employeeId)
|
|
||||||
const maxVersion = sameEmployee.reduce((max, item) => Math.max(max, item.version), 0)
|
|
||||||
const newVersion = maxVersion + 1
|
|
||||||
// 追加新版本(不覆盖旧版本)
|
|
||||||
return [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: `sim-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
||||||
employeeId,
|
|
||||||
name: selectedEmployee.name,
|
|
||||||
department: selectedEmployee.department,
|
|
||||||
reason,
|
|
||||||
reasonLabel,
|
|
||||||
terminationDate,
|
|
||||||
severancePay: costResult.severancePay,
|
|
||||||
noticePay: costResult.noticePay,
|
|
||||||
doublePay: costResult.doublePay,
|
|
||||||
grandTotal: costResult.grandTotal,
|
|
||||||
years: costResult.years,
|
|
||||||
remainingMonths: costResult.remainingMonths,
|
|
||||||
compMonths: costResult.compMonths,
|
|
||||||
version: newVersion,
|
|
||||||
isSimulated: true,
|
|
||||||
createdAt: new Date().toISOString().slice(0, 19).replace('T', ' '),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
handleReset()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存成功后追加正式版本(isSimulated=false)
|
// 保存成功后追加正式版本(isSimulated=false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (saveMutation.isSuccess && costResult && selectedEmployee) {
|
if (saveMutation.isSuccess && costResult && selectedEmployee) {
|
||||||
@@ -731,6 +696,9 @@ export default function Termination() {
|
|||||||
{/* 草稿列表视图 */}
|
{/* 草稿列表视图 */}
|
||||||
{view === 'list' && (
|
{view === 'list' && (
|
||||||
<>
|
<>
|
||||||
|
<PageGuide>
|
||||||
|
解聘补偿页面用于规范处理离职、解聘审批及补偿核算。流程:①新建解聘草稿,选择员工和解聘原因 → ②系统自动计算经济补偿金 → ③合规检查(合同状态、医疗期、孕期等) → ④确认补偿方案 → ⑤生成解聘协议等法律文书。支持协商解除、过错解除、非过错解除、经济性裁员等场景。
|
||||||
|
</PageGuide>
|
||||||
<div className="flex gap-2 flex-wrap items-center">
|
<div className="flex gap-2 flex-wrap items-center">
|
||||||
<Input
|
<Input
|
||||||
placeholder="搜索员工姓名或部门"
|
placeholder="搜索员工姓名或部门"
|
||||||
@@ -844,8 +812,8 @@ export default function Termination() {
|
|||||||
)}
|
)}
|
||||||
{item.status === 'DRAFT' && (
|
{item.status === 'DRAFT' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
if (confirm(`确认执行「${item.employeeName}」的解聘手续?\n确认后员工状态将变更为离职,社保/公积金将停缴,此操作不可撤销。`)) {
|
if (await confirm({ title: '执行解聘', message: `确认执行「${item.employeeName}」的解聘手续?\n确认后员工状态将变更为离职,社保/公积金将停缴,此操作不可撤销。` })) {
|
||||||
setDraftId(item.id)
|
setDraftId(item.id)
|
||||||
executeMutation.mutate()
|
executeMutation.mutate()
|
||||||
}
|
}
|
||||||
@@ -1548,12 +1516,23 @@ export default function Termination() {
|
|||||||
<div>社保截止:{socialInsEndMonth || terminationDate.slice(0, 7)}</div>
|
<div>社保截止:{socialInsEndMonth || terminationDate.slice(0, 7)}</div>
|
||||||
<div>公积金截止:{housingFundEndMonth || terminationDate.slice(0, 7)}</div>
|
<div>公积金截止:{housingFundEndMonth || terminationDate.slice(0, 7)}</div>
|
||||||
{costResult && (
|
{costResult && (
|
||||||
|
<>
|
||||||
|
{compAdjustments.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div>系统预估补偿金:¥{fmt(costResult.grandTotal)}</div>
|
||||||
|
{compAdjustments.map((adj, i) => (
|
||||||
|
<div key={i} className="text-amber-700">
|
||||||
|
手动调整 - {adj.field}:¥{fmt(adj.from)} → ¥{fmt(adj.to)}({adj.reason})
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="font-medium text-primary">实际补偿金合计:¥{fmt(adjustedTotal)}</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<div>补偿金合计:¥{fmt(costResult.grandTotal)}</div>
|
<div>补偿金合计:¥{fmt(costResult.grandTotal)}</div>
|
||||||
)}
|
)}
|
||||||
<div>工作交接:{handoverItems.filter(i => i.done).length}/{handoverItems.length} 项完成</div>
|
</>
|
||||||
{compAdjustments.length > 0 && (
|
|
||||||
<div className="text-amber-700">补偿金已手动调整 {compAdjustments.length} 项</div>
|
|
||||||
)}
|
)}
|
||||||
|
<div>工作交接:{handoverItems.filter(i => i.done).length}/{handoverItems.length} 项完成</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 风险提示 */}
|
{/* 风险提示 */}
|
||||||
@@ -1771,9 +1750,17 @@ export default function Termination() {
|
|||||||
<ChevronLeft className="w-4 h-4 mr-1" />上一步
|
<ChevronLeft className="w-4 h-4 mr-1" />上一步
|
||||||
</Button>
|
</Button>
|
||||||
{step < 4 ? (
|
{step < 4 ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!canProceed() && step === 2 && checklistItems?.some(item => item.suggestionType === 'required' && !checklist[item.key]) && (
|
||||||
|
<span className="text-xs text-danger">请先勾选所有必检项</span>
|
||||||
|
)}
|
||||||
|
{!canProceed() && step === 4 && handoverItems.some(item => (item.key === 'work_handover' || item.key === 'equipment_return' || item.key === 'access_revoke') && !item.done) && (
|
||||||
|
<span className="text-xs text-danger">请先完成工作交接、设备归还、权限收回</span>
|
||||||
|
)}
|
||||||
<Button onClick={() => setStep(step + 1)} disabled={!canProceed()}>
|
<Button onClick={() => setStep(step + 1)} disabled={!canProceed()}>
|
||||||
下一步<ChevronRight className="w-4 h-4 ml-1" />
|
下一步<ChevronRight className="w-4 h-4 ml-1" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
) : step === 4 ? (
|
) : step === 4 ? (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="secondary" onClick={handleSaveDraft} disabled={saveDraftMutation.isPending}>
|
<Button variant="secondary" onClick={handleSaveDraft} disabled={saveDraftMutation.isPending}>
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import {
|
|||||||
Repeat, Pause, FileText, XCircle, UserX, FileMinus, Briefcase,
|
Repeat, Pause, FileText, XCircle, UserX, FileMinus, Briefcase,
|
||||||
Loader2, ChevronRight, Trash2, Send, X, Eye,
|
Loader2, ChevronRight, Trash2, Send, X, Eye,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { workProcessApi } from '../lib/api-services'
|
import { workProcessApi, templatesApi } from '../lib/api-services'
|
||||||
import Card from '../components/ui/Card'
|
import Card from '../components/ui/Card'
|
||||||
import Button from '../components/ui/Button'
|
import Button from '../components/ui/Button'
|
||||||
import { Input, Label, Select } from '../components/ui/Input'
|
import { Input, Label, Select } from '../components/ui/Input'
|
||||||
import Modal from '../components/ui/Modal'
|
import Modal from '../components/ui/Modal'
|
||||||
import Pagination from '../components/ui/Pagination'
|
import Pagination from '../components/ui/Pagination'
|
||||||
|
import PageGuide from '../components/ui/PageGuide'
|
||||||
|
import QueryError from '../components/ui/QueryError'
|
||||||
|
|
||||||
const PROCESS_ICONS: Record<string, any> = {
|
const PROCESS_ICONS: Record<string, any> = {
|
||||||
HIRE: UserPlus, ONBOARD: LogIn, CUSTOM_CONTRACT: FileSignature,
|
HIRE: UserPlus, ONBOARD: LogIn, CUSTOM_CONTRACT: FileSignature,
|
||||||
@@ -48,7 +50,7 @@ const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 各流程类型的表单字段配置
|
// 各流程类型的表单字段配置
|
||||||
const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | 'date' | 'number' | 'select' | 'textarea'; options?: string[] }[]> = {
|
const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | 'date' | 'number' | 'select' | 'textarea' | 'enterprise-template'; options?: string[] }[]> = {
|
||||||
HIRE: [
|
HIRE: [
|
||||||
{ key: 'name', label: '员工姓名', type: 'text' },
|
{ key: 'name', label: '员工姓名', type: 'text' },
|
||||||
{ key: 'department', label: '部门', type: 'text' },
|
{ key: 'department', label: '部门', type: 'text' },
|
||||||
@@ -99,6 +101,7 @@ const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | '
|
|||||||
{ key: 'suspendDate', label: '中止日期', type: 'date' },
|
{ key: 'suspendDate', label: '中止日期', type: 'date' },
|
||||||
],
|
],
|
||||||
INCOME_CERT: [
|
INCOME_CERT: [
|
||||||
|
{ key: 'enterpriseTemplateId', label: '关联企业模板(选填)', type: 'enterprise-template' },
|
||||||
{ key: 'employeeName', label: '员工姓名', type: 'text' },
|
{ key: 'employeeName', label: '员工姓名', type: 'text' },
|
||||||
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
|
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
|
||||||
{ key: 'position', label: '职务', type: 'text' },
|
{ key: 'position', label: '职务', type: 'text' },
|
||||||
@@ -120,6 +123,7 @@ const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | '
|
|||||||
{ key: 'compensation', label: '经济补偿金', type: 'number' },
|
{ key: 'compensation', label: '经济补偿金', type: 'number' },
|
||||||
],
|
],
|
||||||
LEAVING_CERT: [
|
LEAVING_CERT: [
|
||||||
|
{ key: 'enterpriseTemplateId', label: '关联企业模板(选填)', type: 'enterprise-template' },
|
||||||
{ key: 'employeeName', label: '员工姓名', type: 'text' },
|
{ key: 'employeeName', label: '员工姓名', type: 'text' },
|
||||||
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
|
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
|
||||||
{ key: 'position', label: '职务', type: 'text' },
|
{ key: 'position', label: '职务', type: 'text' },
|
||||||
@@ -137,6 +141,12 @@ const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | '
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 字段 key → 中文 label 映射(用于展示已保存的表单数据)
|
||||||
|
const FIELD_LABEL_MAP: Record<string, string> = Object.values(FORM_FIELDS).flat().reduce((acc, f) => {
|
||||||
|
acc[f.key] = f.label
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, string>)
|
||||||
|
|
||||||
export default function WorkProcess() {
|
export default function WorkProcess() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
@@ -149,7 +159,7 @@ export default function WorkProcess() {
|
|||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [pageSize, setPageSize] = useState(20)
|
const [pageSize, setPageSize] = useState(20)
|
||||||
|
|
||||||
const { data: listData, isLoading } = useQuery({
|
const { data: listData, isLoading, isError, error, refetch } = useQuery({
|
||||||
queryKey: ['work-processes', filterType, filterStatus, page, pageSize],
|
queryKey: ['work-processes', filterType, filterStatus, page, pageSize],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params: any = { page, pageSize }
|
const params: any = { page, pageSize }
|
||||||
@@ -240,6 +250,9 @@ export default function WorkProcess() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<PageGuide>
|
||||||
|
用工办理用于管理员工入职、转正、调岗、离职等全生命周期手续。选择办理类型后填写相关信息并提交,系统自动生成对应文书并更新员工档案。支持批量办理和流程跟踪。
|
||||||
|
</PageGuide>
|
||||||
{/* 发起办理 */}
|
{/* 发起办理 */}
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
@@ -294,6 +307,8 @@ export default function WorkProcess() {
|
|||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-8"><Loader2 className="w-5 h-5 animate-spin text-gray-400" /></div>
|
<div className="flex items-center justify-center py-8"><Loader2 className="w-5 h-5 animate-spin text-gray-400" /></div>
|
||||||
|
) : isError ? (
|
||||||
|
<QueryError error={error} onRetry={refetch} />
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<div className="text-center py-8 text-sm text-gray-400">暂无办理记录</div>
|
<div className="text-center py-8 text-sm text-gray-400">暂无办理记录</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -371,6 +386,8 @@ export default function WorkProcess() {
|
|||||||
value={formData[field.key] || ''}
|
value={formData[field.key] || ''}
|
||||||
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
) : field.type === 'enterprise-template' ? (
|
||||||
|
<EnterpriseTemplateSelect value={formData[field.key] || ''} onChange={(v) => handleFieldChange(field.key, v)} />
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
|
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
|
||||||
@@ -452,8 +469,8 @@ function DetailContent({ id, previewContent, onPreview, onSubmit, onCancel, onDe
|
|||||||
<div className="bg-gray-50 rounded-md p-3 space-y-1">
|
<div className="bg-gray-50 rounded-md p-3 space-y-1">
|
||||||
{Object.entries(data.formData || {}).map(([key, value]: [string, any]) => (
|
{Object.entries(data.formData || {}).map(([key, value]: [string, any]) => (
|
||||||
<div key={key} className="flex text-xs">
|
<div key={key} className="flex text-xs">
|
||||||
<span className="text-gray-500 w-28 shrink-0">{key}</span>
|
<span className="text-gray-500 w-28 shrink-0">{FIELD_LABEL_MAP[key] || key}</span>
|
||||||
<span className="text-gray-900">{String(value)}</span>
|
<span className="text-gray-900">{key === 'enterpriseTemplateId' && value ? `已关联企业模板` : (value ? String(value) : '-')}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{Object.keys(data.formData || {}).length === 0 && <span className="text-xs text-gray-400">无表单数据</span>}
|
{Object.keys(data.formData || {}).length === 0 && <span className="text-xs text-gray-400">无表单数据</span>}
|
||||||
@@ -508,3 +525,27 @@ function DetailContent({ id, previewContent, onPreview, onSubmit, onCancel, onDe
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EnterpriseTemplateSelect({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
|
const { data, isLoading } = useQuery<any>({
|
||||||
|
queryKey: ['enterprise-templates-for-cert'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await templatesApi.enterpriseList({ pageSize: 100 } as any)
|
||||||
|
return res
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const items = data?.items || []
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Select value={value} onChange={(e) => onChange(e.target.value)} disabled={isLoading}>
|
||||||
|
<option value="">{isLoading ? '加载中...' : '使用系统默认模板'}</option>
|
||||||
|
{items.map((t: any) => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{items.length === 0 && !isLoading && (
|
||||||
|
<p className="text-xs text-gray-400 mt-1">暂无企业模板,可前往「模板库」→「企业文本库」创建</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Scale, Loader2, Plus, Trash2, Save, History } from 'lucide-react'
|
||||||
|
import { aiApi, employeeApi } from '../../lib/api-services'
|
||||||
|
import Card from '../../components/ui/Card'
|
||||||
|
import Button from '../../components/ui/Button'
|
||||||
|
import { Input, Label, Select } from '../../components/ui/Input'
|
||||||
|
import Modal from '../../components/ui/Modal'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/** 解析 markdown 内联格式(**bold** 和 `code`)为 TextRun 数组 */
|
||||||
|
|
||||||
|
|
||||||
|
// 通用 AI 历史记录 hook
|
||||||
|
function useAIHistory(type: 'predict' | 'review' | 'case') {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const queryKey = [`ai-history-${type}`]
|
||||||
|
|
||||||
|
const { data: history } = useQuery<any[]>({
|
||||||
|
queryKey,
|
||||||
|
queryFn: async () => {
|
||||||
|
return await aiApi.conversations(type)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async ({ title, input, result }: { title: string; input: string; result: string }) => {
|
||||||
|
const res = await aiApi.createConversation({
|
||||||
|
title: `${type}:${title}`,
|
||||||
|
messages: [{ role: 'user', content: input }, { role: 'assistant', content: result }],
|
||||||
|
}) as any
|
||||||
|
return res
|
||||||
|
},
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => aiApi.removeConversation(id),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadHistory = useCallback(async (id: string) => {
|
||||||
|
return await aiApi.conversation(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { history, saveMutation, deleteMutation, loadHistory }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用历史记录栏组件
|
||||||
|
function HistoryBar({ history, onLoad, onDelete }: {
|
||||||
|
history: any[]
|
||||||
|
onLoad: (id: string) => void
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="border-b pb-2 max-h-40 overflow-y-auto">
|
||||||
|
{history.length > 0 ? history.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={() => onLoad(c.id)}>
|
||||||
|
{c.title.replace(/^(predict:|review:|case:)/, '')}
|
||||||
|
</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(); onDelete(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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 [showHistory, setShowHistory] = useState(false)
|
||||||
|
const [showTodoModal, setShowTodoModal] = useState(false)
|
||||||
|
const [todoEmployeeId, setTodoEmployeeId] = useState('')
|
||||||
|
const [todoTitle, setTodoTitle] = useState('')
|
||||||
|
const [todoLevel, setTodoLevel] = useState('MEDIUM')
|
||||||
|
const [todoType, setTodoType] = useState('TERMINATION')
|
||||||
|
const [creatingTodo, setCreatingTodo] = useState(false)
|
||||||
|
const { history, saveMutation, deleteMutation, loadHistory } = useAIHistory('case')
|
||||||
|
|
||||||
|
const { data: employees } = useQuery<any[]>({
|
||||||
|
queryKey: ['employee-list'],
|
||||||
|
queryFn: () => employeeApi.list({ status: 'ACTIVE' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleMatch = async () => {
|
||||||
|
if (!scenario.trim()) return
|
||||||
|
setLoading(true)
|
||||||
|
setResult('')
|
||||||
|
try {
|
||||||
|
const res = await aiApi.matchCase(scenario) as any
|
||||||
|
setResult(res.result)
|
||||||
|
// 自动保存到历史
|
||||||
|
if (res?.result && !res.result.startsWith('出错了')) {
|
||||||
|
const title = scenario.slice(0, 30).replace(/\n/g, ' ')
|
||||||
|
saveMutation.mutate({ title, input: scenario, result: res.data.result })
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setResult(`出错了:${err.response?.data?.error?.message || '请稍后重试'}`)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoadHistory = async (id: string) => {
|
||||||
|
const data = await loadHistory(id)
|
||||||
|
if (data?.messages) {
|
||||||
|
const userMsg = data.messages.find((m: any) => m.role === 'user')
|
||||||
|
const assistantMsg = data.messages.find((m: any) => m.role === 'assistant')
|
||||||
|
if (userMsg) setScenario(userMsg.content)
|
||||||
|
if (assistantMsg) setResult(assistantMsg.content)
|
||||||
|
setShowHistory(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!saveEmployeeId || !result) return
|
||||||
|
try {
|
||||||
|
await aiApi.reviewSave({ employeeId: saveEmployeeId, type: 'CASE', input: scenario, result })
|
||||||
|
setShowSaveModal(false)
|
||||||
|
setSaveEmployeeId('')
|
||||||
|
toast.success('已保存到员工档案')
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error('保存失败:' + (err.response?.data?.error?.message || '请稍后重试'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateTodo = async () => {
|
||||||
|
if (!todoEmployeeId || !todoTitle) return
|
||||||
|
setCreatingTodo(true)
|
||||||
|
try {
|
||||||
|
await aiApi.caseToTodo({
|
||||||
|
employeeId: todoEmployeeId,
|
||||||
|
title: todoTitle,
|
||||||
|
description: result.slice(0, 500),
|
||||||
|
level: todoLevel,
|
||||||
|
type: todoType,
|
||||||
|
})
|
||||||
|
setShowTodoModal(false)
|
||||||
|
setTodoEmployeeId('')
|
||||||
|
setTodoTitle('')
|
||||||
|
toast.success('已创建待办风险项')
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error('创建失败:' + (err.response?.data?.error?.message || '请稍后重试'))
|
||||||
|
} finally {
|
||||||
|
setCreatingTodo(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Scale className="w-5 h-5 text-primary" />
|
||||||
|
<h2 className="text-sm font-medium">案例匹配</h2>
|
||||||
|
<Button size="sm" variant="secondary" className="ml-auto" onClick={() => setShowHistory(!showHistory)}><History className="w-4 h-4 mr-1" />历史记录</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showHistory && (
|
||||||
|
<div className="mt-2 mb-3">
|
||||||
|
<HistoryBar history={history || []} onLoad={handleLoadHistory} onDelete={(id) => deleteMutation.mutate(id)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-3">
|
||||||
|
<Label>描述你的争议情形</Label>
|
||||||
|
<textarea
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-xs min-h-[150px] resize-y"
|
||||||
|
placeholder="例如:员工入职3个月没签合同,现在要辞退他..."
|
||||||
|
value={scenario}
|
||||||
|
onChange={(e) => setScenario(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Button onClick={handleMatch} disabled={loading || !scenario.trim()}>
|
||||||
|
{loading ? <><Loader2 className="w-4 h-4 animate-spin mr-1" />分析中...</> : '分析'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="font-medium">分析结果</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => setShowTodoModal(true)}><Plus className="w-4 h-4 mr-1" />转待办</Button>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => setShowSaveModal(true)}><Save className="w-4 h-4 mr-1" />保存到员工档案</Button>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showTodoModal && (
|
||||||
|
<Modal open onClose={() => setShowTodoModal(false)}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="font-medium">转为待办风险项</h3>
|
||||||
|
<div>
|
||||||
|
<Label>选择员工</Label>
|
||||||
|
<Select value={todoEmployeeId} onChange={(e) => setTodoEmployeeId(e.target.value)}>
|
||||||
|
<option value="">选择员工</option>
|
||||||
|
{(employees || []).map((e: any) => <option key={e.id} value={e.id}>{e.name}({e.department})</option>)}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>待办标题</Label>
|
||||||
|
<Input value={todoTitle} onChange={(e) => setTodoTitle(e.target.value)} placeholder="如:未签合同风险处理" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>风险等级</Label>
|
||||||
|
<Select value={todoLevel} onChange={(e) => setTodoLevel(e.target.value)}>
|
||||||
|
<option value="HIGH">高</option>
|
||||||
|
<option value="MEDIUM">中</option>
|
||||||
|
<option value="LOW">低</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>风险类型</Label>
|
||||||
|
<Select value={todoType} onChange={(e) => setTodoType(e.target.value)}>
|
||||||
|
<option value="CONTRACT">合同</option>
|
||||||
|
<option value="SALARY">薪酬</option>
|
||||||
|
<option value="TERMINATION">解聘</option>
|
||||||
|
<option value="MONTHLY">月度</option>
|
||||||
|
<option value="ONBOARDING">入职</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">分析结果将作为待办描述自动填入</div>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => setShowTodoModal(false)}>取消</Button>
|
||||||
|
<Button size="sm" onClick={handleCreateTodo} disabled={!todoEmployeeId || !todoTitle || creatingTodo}>
|
||||||
|
{creatingTodo ? '创建中...' : '创建待办'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Send, Loader2, Mic, Plus, MessageSquare, Trash2, UserCheck } from 'lucide-react'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import { aiApi } from '../../lib/api-services'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import Button from '../../components/ui/Button'
|
||||||
|
import { Input, Label, Select } from '../../components/ui/Input'
|
||||||
|
import Modal from '../../components/ui/Modal'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/** 解析 markdown 内联格式(**bold** 和 `code`)为 TextRun 数组 */
|
||||||
|
|
||||||
|
|
||||||
|
// 通用 AI 历史记录 hook
|
||||||
|
|
||||||
|
// 通用历史记录栏组件
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUICK_QUESTIONS = [
|
||||||
|
'员工入职没签合同怎么办?',
|
||||||
|
'加班费怎么算?',
|
||||||
|
'辞退员工需要赔多少?',
|
||||||
|
'试用期最长可以约定几个月?',
|
||||||
|
]
|
||||||
|
|
||||||
|
export 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 [showConsultModal, setShowConsultModal] = useState(false)
|
||||||
|
const [consultForm, setConsultForm] = useState({ type: 'LEGAL' as string, title: '', description: '', contactName: '', contactPhone: '', remark: '' })
|
||||||
|
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 () => {
|
||||||
|
return await aiApi.conversations('chat')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteConvMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => aiApi.removeConversation(id),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ai-conversations'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const consultMutation = useMutation({
|
||||||
|
mutationFn: async (data: typeof consultForm) => {
|
||||||
|
return await aiApi.consult(data)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('已提交咨询请求,专业律师将尽快与您联系')
|
||||||
|
setShowConsultModal(false)
|
||||||
|
setConsultForm({ type: 'LEGAL', title: '', description: '', contactName: '', contactPhone: '', remark: '' })
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast.error(err?.message || '提交失败,请稍后重试')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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 = `chat:${messages.find(m => m.role === 'user')?.content.slice(0, 30) || '新对话'}`
|
||||||
|
if (currentConvId) {
|
||||||
|
await aiApi.updateConversation(currentConvId, { messages }).catch(() => {})
|
||||||
|
} else {
|
||||||
|
const res = await aiApi.createConversation({ title, messages }) as any
|
||||||
|
if (res?.id) {
|
||||||
|
setCurrentConvId(res.id)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['ai-conversations'] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
const loadConversation = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const res = await aiApi.conversation(id) as any
|
||||||
|
if (res?.messages) {
|
||||||
|
setMessages(res.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) {
|
||||||
|
toast.error('当前浏览器不支持语音输入,请使用 Chrome 或 Edge')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (recording) {
|
||||||
|
recognitionRef.current?.stop()
|
||||||
|
setRecording(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const recognition = new SpeechRecognition()
|
||||||
|
recognition.lang = 'zh-CN'
|
||||||
|
recognition.continuous = false
|
||||||
|
recognition.interimResults = false
|
||||||
|
recognition.onresult = (event: any) => {
|
||||||
|
const transcript = event.results[0]?.[0]?.transcript || ''
|
||||||
|
setInput((prev) => prev + transcript)
|
||||||
|
}
|
||||||
|
recognition.onerror = () => setRecording(false)
|
||||||
|
recognition.onend = () => setRecording(false)
|
||||||
|
recognition.start()
|
||||||
|
recognitionRef.current = recognition
|
||||||
|
setRecording(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const send = async (text?: string) => {
|
||||||
|
const content = text || input.trim()
|
||||||
|
if (!content || loading) return
|
||||||
|
|
||||||
|
const newMessages = [...messages, { role: 'user' as const, content }]
|
||||||
|
setMessages([...newMessages, { role: 'assistant', content: '' }])
|
||||||
|
setInput('')
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = useAuthStore.getState().accessToken
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 60 * 1000)
|
||||||
|
const chatUrl = import.meta.env.DEV ? 'http://localhost:3000/api/v1/ai/chat-stream' : '/api/v1/ai/chat-stream'
|
||||||
|
const response = await fetch(chatUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ messages: newMessages }),
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errData = await response.json().catch(() => null)
|
||||||
|
throw new Error(errData?.error?.message || '请求失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let accumulated = ''
|
||||||
|
let buffer = ''
|
||||||
|
let rafId: number | null = null
|
||||||
|
let pendingFlush = false
|
||||||
|
|
||||||
|
// 用 RAF 批量刷新,避免每个 token 触发一次 React 重渲染
|
||||||
|
const flush = () => {
|
||||||
|
pendingFlush = false
|
||||||
|
rafId = null
|
||||||
|
setMessages([...newMessages, { role: 'assistant', content: accumulated }])
|
||||||
|
}
|
||||||
|
const scheduleFlush = () => {
|
||||||
|
if (!pendingFlush) {
|
||||||
|
pendingFlush = true
|
||||||
|
rafId = requestAnimationFrame(flush)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader) {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
const data = line.slice(6).trim()
|
||||||
|
if (data === '[DONE]') continue
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
if (parsed.delta) {
|
||||||
|
accumulated += parsed.delta
|
||||||
|
scheduleFlush()
|
||||||
|
}
|
||||||
|
if (parsed.error) {
|
||||||
|
throw new Error(parsed.error)
|
||||||
|
}
|
||||||
|
} catch (parseErr: any) {
|
||||||
|
// 只有业务错误(有 message 且不是 SyntaxError)才抛出
|
||||||
|
if (parseErr instanceof SyntaxError) {
|
||||||
|
// JSON 解析失败,可能是 SSE 分块截断,跳过等下一块
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
throw parseErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 确保最后一批内容被刷新
|
||||||
|
if (rafId) cancelAnimationFrame(rafId)
|
||||||
|
if (accumulated) {
|
||||||
|
setMessages([...newMessages, { role: 'assistant', content: accumulated }])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!accumulated) {
|
||||||
|
setMessages([...newMessages, { role: 'assistant', content: '(无回复内容)' }])
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const isTimeout = err.name === 'AbortError'
|
||||||
|
setMessages([...newMessages, { role: 'assistant', content: isTimeout ? '请求超时,AI 服务响应时间过长,请稍后重试或简化问题。' : `抱歉,出错了:${err.message || '请稍后重试'}` }])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => setShowConsultModal(true)}><UserCheck 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.replace(/^chat:/, '')}</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'}`}>
|
||||||
|
<div className={`max-w-[85%] px-4 py-3 rounded-lg text-sm leading-relaxed ${
|
||||||
|
msg.role === 'user' ? 'bg-primary text-white whitespace-pre-wrap' : 'bg-white border border-gray-200 text-gray-800 shadow-sm'
|
||||||
|
}`}>
|
||||||
|
{msg.role === 'assistant' ? (
|
||||||
|
msg.content ? (
|
||||||
|
<div className="prose prose-sm max-w-none
|
||||||
|
prose-headings:text-gray-900 prose-headings:font-semibold
|
||||||
|
prose-h1:text-base prose-h1:mt-4 prose-h1:mb-2
|
||||||
|
prose-h2:text-sm prose-h2:mt-3 prose-h2:mb-2
|
||||||
|
prose-h3:text-sm prose-h3:mt-2 prose-h3:mb-1
|
||||||
|
prose-p:my-2 prose-p:leading-relaxed
|
||||||
|
prose-li:my-0.5 prose-li:leading-relaxed
|
||||||
|
prose-ul:my-2 prose-ol:my-2
|
||||||
|
prose-code:text-pink-600 prose-code:bg-gray-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-xs prose-code:before:content-none prose-code:after:content-none
|
||||||
|
prose-pre:bg-gray-900 prose-pre:text-gray-100 prose-pre:rounded-lg prose-pre:p-3 prose-pre:my-3
|
||||||
|
prose-blockquote:border-l-primary prose-blockquote:bg-primary/5 prose-blockquote:py-1 prose-blockquote:px-3 prose-blockquote:rounded-r prose-blockquote:not-italic
|
||||||
|
prose-table:text-xs prose-table:border-collapse
|
||||||
|
prose-th:bg-gray-50 prose-th:px-3 prose-th:py-1.5 prose-th:font-semibold prose-th:border prose-th:border-gray-200
|
||||||
|
prose-td:px-3 prose-td:py-1.5 prose-td:border prose-td:border-gray-200
|
||||||
|
prose-a:text-primary prose-a:no-underline hover:prose-a:underline
|
||||||
|
prose-strong:text-gray-900
|
||||||
|
prose-hr:border-gray-200 prose-hr:my-4
|
||||||
|
">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
) : loading && i === messages.length - 1 ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-gray-500">
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
思考中...
|
||||||
|
</span>
|
||||||
|
) : null
|
||||||
|
) : (
|
||||||
|
msg.content
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 快捷问题 */}
|
||||||
|
{messages.length <= 1 && (
|
||||||
|
<div className="flex flex-wrap gap-2 pb-3">
|
||||||
|
{QUICK_QUESTIONS.map((q) => (
|
||||||
|
<button
|
||||||
|
key={q}
|
||||||
|
onClick={() => send(q)}
|
||||||
|
className="px-3 py-1.5 text-xs rounded-full border border-gray-300 text-gray-600 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
{q}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 输入框 */}
|
||||||
|
<div className="flex gap-2 pt-2 border-t">
|
||||||
|
<Input
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && send()}
|
||||||
|
placeholder="输入问题..."
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<Button variant="secondary" onClick={toggleVoice} disabled={loading} className={recording ? 'text-danger' : ''}>
|
||||||
|
<Mic className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => send()} disabled={loading || !input.trim()}>
|
||||||
|
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 转人工咨询 Modal */}
|
||||||
|
{showConsultModal && (
|
||||||
|
<Modal open={true} title="联系专业律师" onClose={() => setShowConsultModal(false)}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-md bg-blue-50 border border-blue-200 p-3 text-xs text-blue-700">
|
||||||
|
<p className="font-medium mb-1">服务说明</p>
|
||||||
|
<p>· <strong>法律咨询</strong>:专业律师在线解答劳动法问题</p>
|
||||||
|
<p>· <strong>仲裁代理</strong>:律师代理劳动仲裁案件(付费服务)</p>
|
||||||
|
<p>· <strong>出庭服务</strong>:律师代理法院诉讼(付费服务)</p>
|
||||||
|
<p className="mt-1">提交后律师将在 24 小时内与您联系。</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>服务类型</Label>
|
||||||
|
<Select value={consultForm.type} onChange={(e) => setConsultForm({ ...consultForm, type: e.target.value })}>
|
||||||
|
<option value="LEGAL">法律咨询</option>
|
||||||
|
<option value="ARBITRATION">仲裁代理(付费)</option>
|
||||||
|
<option value="COURT">出庭服务(付费)</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>问题标题</Label>
|
||||||
|
<Input value={consultForm.title} onChange={(e) => setConsultForm({ ...consultForm, title: e.target.value })} placeholder="简要描述您的问题" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>详细描述</Label>
|
||||||
|
<textarea
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm min-h-[80px] resize-y"
|
||||||
|
value={consultForm.description}
|
||||||
|
onChange={(e) => setConsultForm({ ...consultForm, description: e.target.value })}
|
||||||
|
placeholder="请详细描述您遇到的法律问题、涉及的员工情况等"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>联系人姓名</Label>
|
||||||
|
<Input value={consultForm.contactName} onChange={(e) => setConsultForm({ ...consultForm, contactName: e.target.value })} placeholder="您的姓名" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>联系电话</Label>
|
||||||
|
<Input value={consultForm.contactPhone} onChange={(e) => setConsultForm({ ...consultForm, contactPhone: e.target.value })} placeholder="手机号码" maxLength={11} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>备注(可选)</Label>
|
||||||
|
<Input value={consultForm.remark} onChange={(e) => setConsultForm({ ...consultForm, remark: e.target.value })} placeholder="其他需要说明的信息" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end pt-2">
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => setShowConsultModal(false)}>取消</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => consultMutation.mutate(consultForm)}
|
||||||
|
disabled={consultMutation.isPending || !consultForm.title || !consultForm.description || !consultForm.contactName || !consultForm.contactPhone}
|
||||||
|
>
|
||||||
|
{consultMutation.isPending ? '提交中...' : '提交咨询'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { useState, useRef } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Sparkles, Loader2, Download, TrendingUp } from 'lucide-react'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import rehypeRaw from 'rehype-raw'
|
||||||
|
import { Document, Packer, Paragraph, HeadingLevel, TextRun, Table, TableRow, TableCell, WidthType, BorderStyle, AlignmentType } from 'docx'
|
||||||
|
import { saveAs } from 'file-saver'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import Card from '../../components/ui/Card'
|
||||||
|
import Button from '../../components/ui/Button'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/** 解析 markdown 内联格式(**bold** 和 `code`)为 TextRun 数组 */
|
||||||
|
function parseInlineBold(text: string): TextRun[] {
|
||||||
|
const runs: TextRun[] = []
|
||||||
|
const regex = /(\*\*(.+?)\*\*|`(.+?)`)/g
|
||||||
|
let lastIndex = 0
|
||||||
|
let match
|
||||||
|
while ((match = regex.exec(text)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
runs.push(new TextRun({ text: text.slice(lastIndex, match.index) }))
|
||||||
|
}
|
||||||
|
if (match[2]) {
|
||||||
|
runs.push(new TextRun({ text: match[2], bold: true }))
|
||||||
|
} else if (match[3]) {
|
||||||
|
runs.push(new TextRun({ text: match[3], font: 'Courier New', size: 20 }))
|
||||||
|
}
|
||||||
|
lastIndex = regex.lastIndex
|
||||||
|
}
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
runs.push(new TextRun({ text: text.slice(lastIndex) }))
|
||||||
|
}
|
||||||
|
return runs.length ? runs : [new TextRun({ text })]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出 Markdown 文本为 Word 文档 */
|
||||||
|
async function exportMarkdownToWord(markdown: string, fileName: string) {
|
||||||
|
const lines = markdown.split('\n')
|
||||||
|
const children: (Paragraph | Table)[] = []
|
||||||
|
let i = 0
|
||||||
|
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i]
|
||||||
|
if (!line.trim()) { i++; continue }
|
||||||
|
if (line.includes('|') && i + 1 < lines.length && lines[i + 1].includes('---')) {
|
||||||
|
const headerCells = line.split('|').map(c => c.trim()).filter(Boolean)
|
||||||
|
i += 2
|
||||||
|
const rows: TableRow[] = []
|
||||||
|
rows.push(new TableRow({
|
||||||
|
children: headerCells.map(text => new TableCell({
|
||||||
|
children: [new Paragraph({ children: [new TextRun({ text, bold: true })] })],
|
||||||
|
shading: { fill: 'F3F4F6' },
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
while (i < lines.length && lines[i].includes('|') && lines[i].trim()) {
|
||||||
|
const cells = lines[i].split('|').map(c => c.trim()).filter(Boolean)
|
||||||
|
rows.push(new TableRow({
|
||||||
|
children: cells.map(text => new TableCell({
|
||||||
|
children: [new Paragraph({ children: [new TextRun({ text })] })],
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
children.push(new Table({ rows, width: { size: 100, type: WidthType.PERCENTAGE } }))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (line.startsWith('### ')) {
|
||||||
|
children.push(new Paragraph({ heading: HeadingLevel.HEADING_3, children: [new TextRun({ text: line.slice(4), bold: true })] }))
|
||||||
|
} else if (line.startsWith('## ')) {
|
||||||
|
children.push(new Paragraph({ heading: HeadingLevel.HEADING_2, children: [new TextRun({ text: line.slice(3), bold: true })] }))
|
||||||
|
} else if (line.startsWith('# ')) {
|
||||||
|
children.push(new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun({ text: line.slice(2), bold: true })] }))
|
||||||
|
} else if (line.startsWith('> ')) {
|
||||||
|
children.push(new Paragraph({ children: [new TextRun({ text: line.slice(2), italics: true })], indent: { left: 720 } }))
|
||||||
|
} else if (line.startsWith('- ') || line.startsWith('* ')) {
|
||||||
|
children.push(new Paragraph({ children: parseInlineBold(line.slice(2)), bullet: { level: 0 } }))
|
||||||
|
} else if (/^\d+\.\s/.test(line)) {
|
||||||
|
children.push(new Paragraph({ children: parseInlineBold(line.replace(/^\d+\.\s/, '')), numbering: { reference: 'default-numbering', level: 0 } }))
|
||||||
|
} else if (line === '---' || line === '***') {
|
||||||
|
children.push(new Paragraph({ children: [], border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'E5E7EB' } } }))
|
||||||
|
} else {
|
||||||
|
children.push(new Paragraph({ children: parseInlineBold(line) }))
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = new Document({
|
||||||
|
numbering: { config: [{ reference: 'default-numbering', levels: [{ level: 0, format: 'decimal', text: '%1.', alignment: AlignmentType.START }] }] },
|
||||||
|
sections: [{ children }],
|
||||||
|
})
|
||||||
|
const blob = await Packer.toBlob(doc)
|
||||||
|
saveAs(blob, fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用 AI 历史记录 hook
|
||||||
|
|
||||||
|
// 通用历史记录栏组件
|
||||||
|
|
||||||
|
export function HRReportTab() {
|
||||||
|
const [result, setResult] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
if (loading) return
|
||||||
|
abortRef.current?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortRef.current = controller
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setResult('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = useAuthStore.getState().accessToken
|
||||||
|
const url = import.meta.env.DEV
|
||||||
|
? `http://localhost:3000/api/v1/ai/hr-report-stream`
|
||||||
|
: `/api/v1/ai/hr-report-stream`
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errData = await response.json().catch(() => null)
|
||||||
|
throw new Error(errData?.error?.message || '请求失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let accumulated = ''
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
if (reader) {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
const data = line.slice(6).trim()
|
||||||
|
if (data === '[DONE]') continue
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
if (parsed.delta) {
|
||||||
|
accumulated += parsed.delta
|
||||||
|
setResult(accumulated)
|
||||||
|
}
|
||||||
|
if (parsed.error) {
|
||||||
|
throw new Error(parsed.error)
|
||||||
|
}
|
||||||
|
} catch (parseErr: any) {
|
||||||
|
if (parseErr instanceof SyntaxError) continue
|
||||||
|
throw parseErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setResult(accumulated)
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name !== 'AbortError') {
|
||||||
|
toast.error(err.message || '生成报告失败')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
if (!result) return
|
||||||
|
try {
|
||||||
|
await exportMarkdownToWord(result, `人力分析报告_${new Date().toISOString().slice(0, 10)}.docx`)
|
||||||
|
toast.success('Word 文档已导出')
|
||||||
|
} catch {
|
||||||
|
toast.error('导出失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-medium flex items-center gap-1.5">
|
||||||
|
<TrendingUp className="w-4 h-4 text-primary" />
|
||||||
|
AI 人力分析报告
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">基于企业实际数据自动生成:人力概况、风险提示、成本分析、合规建议、改进方向</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{result && !loading && (
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
导出 Word
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Button size="sm" onClick={handleGenerate} disabled={loading}>
|
||||||
|
{loading ? (
|
||||||
|
<><Loader2 className="w-4 h-4 mr-1 animate-spin" />生成中...</>
|
||||||
|
) : (
|
||||||
|
<><Sparkles className="w-4 h-4 mr-1" />生成报告</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!result && !loading && (
|
||||||
|
<div className="text-center py-12 text-gray-400">
|
||||||
|
<TrendingUp className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||||
|
<p className="text-sm">点击"生成报告",AI 将基于企业当前数据自动生成结构化人力分析报告</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && !result && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Loader2 className="w-8 h-8 mx-auto mb-3 text-primary animate-spin" />
|
||||||
|
<p className="text-sm text-gray-500">AI 正在分析企业数据并生成报告...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className="prose prose-sm max-w-none
|
||||||
|
prose-headings:text-gray-800 prose-headings:font-semibold
|
||||||
|
prose-h1:text-lg prose-h1:border-b prose-h1:pb-2 prose-h1:border-gray-200
|
||||||
|
prose-h2:text-base prose-h2:mt-4
|
||||||
|
prose-h3:text-sm prose-h3:mt-3
|
||||||
|
prose-p:text-gray-600 prose-p:leading-relaxed
|
||||||
|
prose-li:text-gray-600 prose-li:leading-relaxed
|
||||||
|
prose-strong:text-gray-800
|
||||||
|
prose-code:text-pink-600 prose-code:bg-gray-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-xs prose-code:before:content-none prose-code:after:content-none
|
||||||
|
prose-pre:bg-gray-900 prose-pre:text-gray-100 prose-pre:rounded-lg prose-pre:p-3 prose-pre:my-3
|
||||||
|
prose-blockquote:border-l-primary prose-blockquote:bg-primary/5 prose-blockquote:py-1 prose-blockquote:px-3 prose-blockquote:rounded-r prose-blockquote:not-italic
|
||||||
|
prose-table:text-xs prose-table:border-collapse
|
||||||
|
prose-th:bg-gray-50 prose-th:px-3 prose-th:py-1.5 prose-th:font-semibold prose-th:border prose-th:border-gray-200
|
||||||
|
prose-td:px-3 prose-td:py-1.5 prose-td:border prose-td:border-gray-200
|
||||||
|
prose-a:text-primary prose-a:no-underline hover:prose-a:underline
|
||||||
|
">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||||
|
{result}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Plus, Trash2 } from 'lucide-react'
|
||||||
|
import { aiApi } from '../../lib/api-services'
|
||||||
|
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'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/** 解析 markdown 内联格式(**bold** 和 `code`)为 TextRun 数组 */
|
||||||
|
|
||||||
|
|
||||||
|
// 通用 AI 历史记录 hook
|
||||||
|
|
||||||
|
// 通用历史记录栏组件
|
||||||
|
|
||||||
|
export function KnowledgeTab() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
|
const [newItem, setNewItem] = useState({ title: '', content: '', source: '自定义', category: '其他' })
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
|
||||||
|
const { data: knowledgeList, isLoading } = useQuery<any[]>({
|
||||||
|
queryKey: ['rag-knowledge'],
|
||||||
|
queryFn: async () => {
|
||||||
|
return await aiApi.ragList()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const addMutation = useMutation({
|
||||||
|
mutationFn: async (data: typeof newItem) => {
|
||||||
|
return await aiApi.ragAdd(data)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['rag-knowledge'] })
|
||||||
|
setShowAdd(false)
|
||||||
|
setNewItem({ title: '', content: '', source: '自定义', category: '其他' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => aiApi.ragRemove(id),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['rag-knowledge'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const seedMutation = useMutation({
|
||||||
|
mutationFn: () => aiApi.ragSeed(),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['rag-knowledge'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!newItem.title || !newItem.content) return
|
||||||
|
setAdding(true)
|
||||||
|
try {
|
||||||
|
await addMutation.mutateAsync(newItem)
|
||||||
|
} finally {
|
||||||
|
setAdding(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-md p-3 text-xs text-blue-700 space-y-1">
|
||||||
|
<div className="font-medium">📖 知识库说明</div>
|
||||||
|
<div>· 法律法规知识库由研发方定期更新维护,确保政策时效性</div>
|
||||||
|
<div>· 您可点击「添加知识」上传企业内部制度、操作规范等,AI 问答将同时检索法律法规和企业制度</div>
|
||||||
|
<div>· 如发现法律内容过时,请联系研发方更新</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-gray-500">共 {knowledgeList?.length || 0} 条知识</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => seedMutation.mutate()} disabled={seedMutation.isPending}>
|
||||||
|
{seedMutation.isPending ? '初始化中...' : '初始化知识库'}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => setShowAdd(true)}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />添加知识
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||||
|
) : !knowledgeList || knowledgeList.length === 0 ? (
|
||||||
|
<Card><div className="text-center py-8 text-gray-400">知识库为空,请点击「初始化知识库」</div></Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{knowledgeList.map((item: any) => (
|
||||||
|
<Card key={item.id}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-xs font-medium">{item.title}</span>
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-gray-100 text-gray-500 text-xs">{item.category}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 line-clamp-2">{item.content}</p>
|
||||||
|
<div className="text-xs text-gray-400 mt-1">来源:{item.source}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteMutation.mutate(item.id)}
|
||||||
|
className="text-gray-400 hover:text-danger flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAdd && (
|
||||||
|
<Modal open onClose={() => setShowAdd(false)}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="font-medium">添加知识条目</h3>
|
||||||
|
<div>
|
||||||
|
<Label>标题</Label>
|
||||||
|
<Input value={newItem.title} onChange={(e) => setNewItem({ ...newItem, title: e.target.value })} placeholder="如:劳动合同法第十条" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>内容</Label>
|
||||||
|
<textarea
|
||||||
|
value={newItem.content}
|
||||||
|
onChange={(e) => setNewItem({ ...newItem, content: e.target.value })}
|
||||||
|
placeholder="法律条文或知识内容"
|
||||||
|
rows={5}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-gray-300 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>来源</Label>
|
||||||
|
<Input value={newItem.source} onChange={(e) => setNewItem({ ...newItem, source: e.target.value })} placeholder="如:劳动合同法" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>分类</Label>
|
||||||
|
<Select value={newItem.category} onChange={(e) => setNewItem({ ...newItem, category: e.target.value })}>
|
||||||
|
<option value="其他">其他</option>
|
||||||
|
<option value="法律法规">法律法规</option>
|
||||||
|
<option value="司法解释">司法解释</option>
|
||||||
|
<option value="地方性法规">地方性法规</option>
|
||||||
|
<option value="案例分析">案例分析</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => setShowAdd(false)}>取消</Button>
|
||||||
|
<Button size="sm" onClick={handleAdd} disabled={!newItem.title || !newItem.content || adding}>
|
||||||
|
{adding ? '添加中...' : '添加'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,789 @@
|
|||||||
|
import { useState, useRef, useCallback } from 'react'
|
||||||
|
import { SCENARIO_TYPES } from './shared'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Sparkles, Loader2, Trash2, History, Database, User, AlertTriangle, FileText, Shield, Download } from 'lucide-react'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import { Document, Packer, Paragraph, HeadingLevel, TextRun, Table, TableRow, TableCell, WidthType, BorderStyle, AlignmentType } from 'docx'
|
||||||
|
import { saveAs } from 'file-saver'
|
||||||
|
import { aiApi, rosterApi, employeeApi } from '../../lib/api-services'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import Card from '../../components/ui/Card'
|
||||||
|
import Button from '../../components/ui/Button'
|
||||||
|
import { Input, Label, Select } from '../../components/ui/Input'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/** 解析 markdown 内联格式(**bold** 和 `code`)为 TextRun 数组 */
|
||||||
|
|
||||||
|
|
||||||
|
// 通用 AI 历史记录 hook
|
||||||
|
function useAIHistory(type: 'predict' | 'review' | 'case') {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const queryKey = [`ai-history-${type}`]
|
||||||
|
|
||||||
|
const { data: history } = useQuery<any[]>({
|
||||||
|
queryKey,
|
||||||
|
queryFn: async () => {
|
||||||
|
return await aiApi.conversations(type)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async ({ title, input, result }: { title: string; input: string; result: string }) => {
|
||||||
|
const res = await aiApi.createConversation({
|
||||||
|
title: `${type}:${title}`,
|
||||||
|
messages: [{ role: 'user', content: input }, { role: 'assistant', content: result }],
|
||||||
|
}) as any
|
||||||
|
return res
|
||||||
|
},
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => aiApi.removeConversation(id),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadHistory = useCallback(async (id: string) => {
|
||||||
|
return await aiApi.conversation(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { history, saveMutation, deleteMutation, loadHistory }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用历史记录栏组件
|
||||||
|
function HistoryBar({ history, onLoad, onDelete }: {
|
||||||
|
history: any[]
|
||||||
|
onLoad: (id: string) => void
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="border-b pb-2 max-h-40 overflow-y-auto">
|
||||||
|
{history.length > 0 ? history.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={() => onLoad(c.id)}>
|
||||||
|
{c.title.replace(/^(predict:|review:|case:)/, '')}
|
||||||
|
</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(); onDelete(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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PredictTab() {
|
||||||
|
const [result, setResult] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [mode, setMode] = useState<'general' | 'structured'>('general')
|
||||||
|
const [scope, setScope] = useState('all')
|
||||||
|
const [riskType, setRiskType] = useState('all')
|
||||||
|
const [department, setDepartment] = useState('')
|
||||||
|
const [employeeId, setEmployeeId] = useState('')
|
||||||
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
const { history, saveMutation, deleteMutation, loadHistory } = useAIHistory('predict')
|
||||||
|
|
||||||
|
// 结构化表单状态
|
||||||
|
const [scenarioType, setScenarioType] = useState('discipline')
|
||||||
|
const [structEmployeeName, setStructEmployeeName] = useState('')
|
||||||
|
const [violationFact, setViolationFact] = useState('')
|
||||||
|
const [region, setRegion] = useState('')
|
||||||
|
const [monthlySalary, setMonthlySalary] = useState('')
|
||||||
|
const [democracyStatus, setDemocracyStatus] = useState('')
|
||||||
|
const [disciplinaryRecord, setDisciplinaryRecord] = useState('')
|
||||||
|
const [extraInfo, setExtraInfo] = useState('')
|
||||||
|
// 系统带出字段标记(区分自动填充 vs HR 手动修改)
|
||||||
|
const [autoFilledFields, setAutoFilledFields] = useState<{ salary?: boolean; region?: boolean; disciplinary?: boolean; violationFact?: boolean; extraInfo?: boolean }>({})
|
||||||
|
// 员工特殊状态提示
|
||||||
|
const [employeeSpecialStatus, setEmployeeSpecialStatus] = useState('')
|
||||||
|
|
||||||
|
const { data: employees } = useQuery<any[]>({
|
||||||
|
queryKey: ['employee-list'],
|
||||||
|
queryFn: () => employeeApi.list({ status: 'ACTIVE' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const departments = [...new Set((employees || []).map((e: any) => e.department).filter(Boolean))]
|
||||||
|
|
||||||
|
/** 选择员工后自动带出系统已有数据 */
|
||||||
|
const handleStructEmployeeChange = async (employeeName: string) => {
|
||||||
|
setStructEmployeeName(employeeName)
|
||||||
|
// 清空之前带出的数据
|
||||||
|
setAutoFilledFields({})
|
||||||
|
setEmployeeSpecialStatus('')
|
||||||
|
setExtraInfo('')
|
||||||
|
if (!employeeName) return
|
||||||
|
|
||||||
|
// 从已加载的员工列表中查找(列表数据已含 monthlySalary/isPregnant/city 等字段)
|
||||||
|
const emp = (employees || []).find((e: any) => e.name === employeeName)
|
||||||
|
if (!emp) return
|
||||||
|
|
||||||
|
// 1. 直接从列表数据带出月薪(已解密)
|
||||||
|
if (emp.monthlySalary && Number(emp.monthlySalary) > 0) {
|
||||||
|
setMonthlySalary(String(emp.monthlySalary))
|
||||||
|
setAutoFilledFields((prev) => ({ ...prev, salary: true }))
|
||||||
|
}
|
||||||
|
// 2. 直接从列表数据带出地区
|
||||||
|
if (emp.city) {
|
||||||
|
setRegion(emp.city)
|
||||||
|
setAutoFilledFields((prev) => ({ ...prev, region: true }))
|
||||||
|
}
|
||||||
|
// 3. 直接从列表数据带出特殊状态
|
||||||
|
const statusParts: string[] = []
|
||||||
|
if (emp.isPregnant) statusParts.push('孕期/哺乳期')
|
||||||
|
if (emp.isInMedicalPeriod) statusParts.push('医疗期')
|
||||||
|
if (emp.isWorkInjured) statusParts.push('工伤')
|
||||||
|
const specialStatus = statusParts.join('、')
|
||||||
|
setEmployeeSpecialStatus(specialStatus)
|
||||||
|
// 自动将三期/特殊状态填入补充信息
|
||||||
|
if (specialStatus) {
|
||||||
|
setExtraInfo(`员工特殊状态:${specialStatus}`)
|
||||||
|
setAutoFilledFields((prev) => ({ ...prev, extraInfo: true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 获取违纪记录(列表接口未含明细,需调用专用接口)
|
||||||
|
try {
|
||||||
|
const res = await rosterApi.disciplinary(emp.id) as any
|
||||||
|
const records = res || []
|
||||||
|
if (Array.isArray(records) && records.length > 0) {
|
||||||
|
const typeMap: Record<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反制度', OTHER: '其他' }
|
||||||
|
const actionMap: Record<string, string> = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '辞退' }
|
||||||
|
// 自动填充"违纪/争议事实"文本框
|
||||||
|
const factText = `【系统已有违纪记录】\n${records.map((r: any) =>
|
||||||
|
`- ${r.violationDate?.slice(0, 10) || ''} ${typeMap[r.violationType] || r.violationType}:${r.description || ''}(处理:${actionMap[r.action] || r.action}${r.employeeAck ? ',已签字' : ',未签字'})`
|
||||||
|
).join('\n')}\n\n【本次争议事实】请在此描述当前拟处理的具体情况...`
|
||||||
|
setViolationFact(factText)
|
||||||
|
setAutoFilledFields((prev) => ({ ...prev, violationFact: true }))
|
||||||
|
// 自动填充"违纪记录留痕情况"下拉
|
||||||
|
const hasWrittenAck = records.some((r: any) => r.action === 'WRITTEN_WARNING' && r.employeeAck)
|
||||||
|
const hasWrittenNoAck = records.some((r: any) => r.action === 'WRITTEN_WARNING' && !r.employeeAck)
|
||||||
|
const hasOralOnly = records.every((r: any) => r.action === 'ORAL_WARNING')
|
||||||
|
if (hasWrittenAck) {
|
||||||
|
setDisciplinaryRecord('有书面警告信且员工签收')
|
||||||
|
setAutoFilledFields((prev) => ({ ...prev, disciplinary: true }))
|
||||||
|
} else if (hasWrittenNoAck) {
|
||||||
|
setDisciplinaryRecord('有书面记录但未签收')
|
||||||
|
setAutoFilledFields((prev) => ({ ...prev, disciplinary: true }))
|
||||||
|
} else if (hasOralOnly) {
|
||||||
|
setDisciplinaryRecord('仅有口头警告')
|
||||||
|
setAutoFilledFields((prev) => ({ ...prev, disciplinary: true }))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 无违纪记录
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('获取违纪记录失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通用 SSE 流读取(复用于两种模式) */
|
||||||
|
const streamSSE = async (response: Response, onDone: (accumulated: string) => void) => {
|
||||||
|
const reader = response.body?.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let accumulated = ''
|
||||||
|
let buffer = ''
|
||||||
|
let rafId: number | null = null
|
||||||
|
let pendingFlush = false
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
pendingFlush = false
|
||||||
|
rafId = null
|
||||||
|
setResult(accumulated)
|
||||||
|
}
|
||||||
|
const scheduleFlush = () => {
|
||||||
|
if (!pendingFlush) {
|
||||||
|
pendingFlush = true
|
||||||
|
rafId = requestAnimationFrame(flush)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader) {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
const data = line.slice(6).trim()
|
||||||
|
if (data === '[DONE]') continue
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
if (parsed.delta) {
|
||||||
|
accumulated += parsed.delta
|
||||||
|
scheduleFlush()
|
||||||
|
}
|
||||||
|
if (parsed.error) {
|
||||||
|
throw new Error(parsed.error)
|
||||||
|
}
|
||||||
|
} catch (parseErr: any) {
|
||||||
|
if (parseErr instanceof SyntaxError) continue
|
||||||
|
throw parseErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rafId) cancelAnimationFrame(rafId)
|
||||||
|
setResult(accumulated)
|
||||||
|
if (accumulated && !accumulated.startsWith('**出错了**')) {
|
||||||
|
onDone(accumulated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchPrediction = async () => {
|
||||||
|
if (loading) return
|
||||||
|
abortRef.current?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortRef.current = controller
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setResult('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = useAuthStore.getState().accessToken
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (scope === 'department' && department) params.set('department', department)
|
||||||
|
if (scope === 'employee' && employeeId) params.set('employeeId', employeeId)
|
||||||
|
if (riskType !== 'all') params.set('riskType', riskType)
|
||||||
|
|
||||||
|
const predictUrl = import.meta.env.DEV
|
||||||
|
? `http://localhost:3000/api/v1/ai/predict-stream?${params}`
|
||||||
|
: `/api/v1/ai/predict-stream?${params}`
|
||||||
|
|
||||||
|
const response = await fetch(predictUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errData = await response.json().catch(() => null)
|
||||||
|
throw new Error(errData?.error?.message || '请求失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
await streamSSE(response, (accumulated) => {
|
||||||
|
const scopeLabel = scope === 'all' ? '全部员工' : scope === 'department' ? department : employees?.find((e: any) => e.id === employeeId)?.name || '指定员工'
|
||||||
|
const riskLabel = riskType === 'all' ? '全部类型' : riskType
|
||||||
|
saveMutation.mutate({ title: `${scopeLabel}-${riskLabel}`, input: `范围:${scopeLabel} 类型:${riskLabel}`, result: accumulated })
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name === 'AbortError') return
|
||||||
|
setResult(`**出错了**:${err.message || '请稍后重试'}`)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 结构化判赔预测 */
|
||||||
|
const fetchStructuredPrediction = async () => {
|
||||||
|
if (loading) return
|
||||||
|
abortRef.current?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortRef.current = controller
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setResult('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = useAuthStore.getState().accessToken
|
||||||
|
const predictUrl = import.meta.env.DEV
|
||||||
|
? `http://localhost:3000/api/v1/ai/predict-structured`
|
||||||
|
: `/api/v1/ai/predict-structured`
|
||||||
|
|
||||||
|
const response = await fetch(predictUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
scenarioType,
|
||||||
|
keyFacts: {
|
||||||
|
employeeName: structEmployeeName || undefined,
|
||||||
|
violationFact: violationFact || undefined,
|
||||||
|
region: region || undefined,
|
||||||
|
monthlySalary: monthlySalary || undefined,
|
||||||
|
democracyStatus: democracyStatus || undefined,
|
||||||
|
disciplinaryRecord: disciplinaryRecord || undefined,
|
||||||
|
extraInfo: extraInfo || undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errData = await response.json().catch(() => null)
|
||||||
|
throw new Error(errData?.error?.message || '请求失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const scenarioLabel = SCENARIO_TYPES.find((s) => s.value === scenarioType)?.label || scenarioType
|
||||||
|
await streamSSE(response, (accumulated) => {
|
||||||
|
saveMutation.mutate({
|
||||||
|
title: `判赔-${scenarioLabel}${structEmployeeName ? '-' + structEmployeeName : ''}`,
|
||||||
|
input: `场景:${scenarioLabel} 员工:${structEmployeeName || '未指定'}`,
|
||||||
|
result: accumulated,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name === 'AbortError') return
|
||||||
|
setResult(`**出错了**:${err.message || '请稍后重试'}`)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoadHistory = async (id: string) => {
|
||||||
|
const data = await loadHistory(id)
|
||||||
|
if (data?.messages) {
|
||||||
|
const assistantMsg = data.messages.find((m: any) => m.role === 'assistant')
|
||||||
|
if (assistantMsg) {
|
||||||
|
setResult(assistantMsg.content)
|
||||||
|
setShowHistory(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePredict = () => {
|
||||||
|
if (mode === 'structured') {
|
||||||
|
fetchStructuredPrediction()
|
||||||
|
} else {
|
||||||
|
fetchPrediction()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析 markdown 内联格式(**bold** 和 `code`)为 TextRun 数组 */
|
||||||
|
const parseInlineBold = (text: string): TextRun[] => {
|
||||||
|
const runs: TextRun[] = []
|
||||||
|
const regex = /(\*\*(.+?)\*\*|`(.+?)`)/g
|
||||||
|
let lastIndex = 0
|
||||||
|
let match
|
||||||
|
while ((match = regex.exec(text)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
runs.push(new TextRun({ text: text.slice(lastIndex, match.index) }))
|
||||||
|
}
|
||||||
|
if (match[2]) {
|
||||||
|
runs.push(new TextRun({ text: match[2], bold: true }))
|
||||||
|
} else if (match[3]) {
|
||||||
|
runs.push(new TextRun({ text: match[3], font: 'Courier New', size: 20 }))
|
||||||
|
}
|
||||||
|
lastIndex = regex.lastIndex
|
||||||
|
}
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
runs.push(new TextRun({ text: text.slice(lastIndex) }))
|
||||||
|
}
|
||||||
|
return runs.length ? runs : [new TextRun({ text })]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出 AI 分析结果为 Word 文档 */
|
||||||
|
const handleExportWord = async () => {
|
||||||
|
if (!result) return
|
||||||
|
try {
|
||||||
|
const lines = result.split('\n')
|
||||||
|
const children: (Paragraph | Table)[] = []
|
||||||
|
let i = 0
|
||||||
|
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i]
|
||||||
|
|
||||||
|
// 跳过空行
|
||||||
|
if (!line.trim()) { i++; continue }
|
||||||
|
|
||||||
|
// 表格(markdown GFM 表格语法)
|
||||||
|
if (line.includes('|') && i + 1 < lines.length && lines[i + 1].includes('---')) {
|
||||||
|
const headerCells = line.split('|').map(c => c.trim()).filter(Boolean)
|
||||||
|
i += 2 // 跳过分隔行
|
||||||
|
const rows: TableRow[] = []
|
||||||
|
// 表头
|
||||||
|
rows.push(new TableRow({
|
||||||
|
children: headerCells.map(text => new TableCell({
|
||||||
|
children: [new Paragraph({ children: [new TextRun({ text, bold: true })] })],
|
||||||
|
shading: { fill: 'F3F4F6' },
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
// 数据行
|
||||||
|
while (i < lines.length && lines[i].includes('|') && lines[i].trim()) {
|
||||||
|
const cells = lines[i].split('|').map(c => c.trim()).filter(Boolean)
|
||||||
|
rows.push(new TableRow({
|
||||||
|
children: cells.map(text => new TableCell({
|
||||||
|
children: [new Paragraph({ children: [new TextRun({ text })] })],
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
children.push(new Table({
|
||||||
|
rows,
|
||||||
|
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
if (line.startsWith('### ')) {
|
||||||
|
children.push(new Paragraph({ heading: HeadingLevel.HEADING_3, children: [new TextRun({ text: line.slice(4), bold: true })] }))
|
||||||
|
} else if (line.startsWith('## ')) {
|
||||||
|
children.push(new Paragraph({ heading: HeadingLevel.HEADING_2, children: [new TextRun({ text: line.slice(3), bold: true })] }))
|
||||||
|
} else if (line.startsWith('# ')) {
|
||||||
|
children.push(new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun({ text: line.slice(2), bold: true })] }))
|
||||||
|
} else if (line.startsWith('> ')) {
|
||||||
|
// 引用块
|
||||||
|
children.push(new Paragraph({
|
||||||
|
children: [new TextRun({ text: line.slice(2), italics: true })],
|
||||||
|
indent: { left: 720 },
|
||||||
|
}))
|
||||||
|
} else if (line.startsWith('- ') || line.startsWith('* ')) {
|
||||||
|
// 无序列表
|
||||||
|
children.push(new Paragraph({
|
||||||
|
children: parseInlineBold(line.slice(2)),
|
||||||
|
bullet: { level: 0 },
|
||||||
|
}))
|
||||||
|
} else if (/^\d+\.\s/.test(line)) {
|
||||||
|
// 有序列表
|
||||||
|
children.push(new Paragraph({
|
||||||
|
children: parseInlineBold(line.replace(/^\d+\.\s/, '')),
|
||||||
|
numbering: { reference: 'default-numbering', level: 0 },
|
||||||
|
}))
|
||||||
|
} else if (line === '---' || line === '***') {
|
||||||
|
// 分隔线
|
||||||
|
children.push(new Paragraph({
|
||||||
|
children: [],
|
||||||
|
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'E5E7EB' } },
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
// 普通段落(支持 **bold** 和 `code`)
|
||||||
|
children.push(new Paragraph({
|
||||||
|
children: parseInlineBold(line),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = new Document({
|
||||||
|
numbering: {
|
||||||
|
config: [{
|
||||||
|
reference: 'default-numbering',
|
||||||
|
levels: [{ level: 0, format: 'decimal', text: '%1.', alignment: AlignmentType.START }],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
sections: [{ children }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const blob = await Packer.toBlob(doc)
|
||||||
|
const fileName = mode === 'structured'
|
||||||
|
? `判赔预测报告_${structEmployeeName || '未指定员工'}_${new Date().toISOString().slice(0, 10)}.docx`
|
||||||
|
: `风险预测报告_${new Date().toISOString().slice(0, 10)}.docx`
|
||||||
|
saveAs(blob, fileName)
|
||||||
|
toast.success('Word 文档已导出')
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('导出失败,请重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Sparkles className="w-5 h-5 text-primary" />
|
||||||
|
<h2 className="text-sm font-medium">AI 风险预测</h2>
|
||||||
|
<Button size="sm" variant="secondary" className="ml-auto" onClick={() => setShowHistory(!showHistory)}><History className="w-4 h-4 mr-1" />历史记录</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showHistory && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<HistoryBar history={history || []} onLoad={handleLoadHistory} onDelete={(id) => deleteMutation.mutate(id)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 模式切换 */}
|
||||||
|
<div className="flex gap-1 mb-4 mt-3 border-b pb-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { setMode('general'); setResult('') }}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
mode === 'general' ? 'bg-primary text-white' : 'text-gray-500 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
全员风险扫描
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setMode('structured'); setResult('') }}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
mode === 'structured' ? 'bg-primary text-white' : 'text-gray-500 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
判赔预测(结构化输入)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* === 通用模式筛选条件 === */}
|
||||||
|
{mode === 'general' && (
|
||||||
|
<div className="flex items-center gap-2 mb-4 flex-wrap">
|
||||||
|
<div className="min-w-[120px]">
|
||||||
|
<Button size="sm" onClick={handlePredict} disabled={loading}>
|
||||||
|
{loading ? '分析中...' : result ? '重新预测' : '开始预测'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="whitespace-nowrap">预测范围</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 className="flex items-center gap-2">
|
||||||
|
<Label className="whitespace-nowrap">风险类型</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 className="flex items-center gap-2">
|
||||||
|
<Label className="whitespace-nowrap">部门</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 className="flex items-center gap-2">
|
||||||
|
<Label className="whitespace-nowrap">员工</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* === 通用模式 AI 结果 === */}
|
||||||
|
{mode === 'general' && loading && !result && (
|
||||||
|
<div className="flex items-center gap-2 text-gray-400 py-8">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" /> 正在分析企业用工风险...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mode === 'general' && result && (
|
||||||
|
<div className="prose prose-sm max-w-none mt-4 overflow-x-auto
|
||||||
|
prose-headings:text-gray-900 prose-headings:font-semibold
|
||||||
|
prose-h1:text-base prose-h1:mt-4 prose-h1:mb-2
|
||||||
|
prose-h2:text-sm prose-h2:mt-3 prose-h2:mb-2
|
||||||
|
prose-h3:text-sm prose-h3:mt-2 prose-h3:mb-1
|
||||||
|
prose-p:my-2 prose-p:leading-relaxed
|
||||||
|
prose-li:my-0.5 prose-li:leading-relaxed
|
||||||
|
prose-ul:my-2 prose-ol:my-2
|
||||||
|
prose-code:text-pink-600 prose-code:bg-gray-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-xs prose-code:before:content-none prose-code:after:content-none
|
||||||
|
prose-pre:bg-gray-900 prose-pre:text-gray-100 prose-pre:rounded-lg prose-pre:p-3 prose-pre:my-3
|
||||||
|
prose-blockquote:border-l-primary prose-blockquote:bg-primary/5 prose-blockquote:py-1 prose-blockquote:px-3 prose-blockquote:rounded-r prose-blockquote:not-italic
|
||||||
|
prose-table:text-xs prose-table:border-collapse
|
||||||
|
prose-th:bg-gray-100 prose-th:px-2 prose-th:py-1.5 prose-th:font-semibold prose-th:border prose-th:border-gray-300 prose-th:whitespace-nowrap
|
||||||
|
prose-td:px-2 prose-td:py-1.5 prose-td:border prose-td:border-gray-300 prose-td:align-top
|
||||||
|
prose-a:text-primary prose-a:no-underline hover:prose-a:underline
|
||||||
|
prose-strong:text-gray-900
|
||||||
|
prose-hr:border-gray-200 prose-hr:my-4">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{result}</ReactMarkdown>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 animate-spin inline-block text-gray-400 ml-1" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mode === 'general' && !result && !loading && (
|
||||||
|
<div className="text-center py-8 text-gray-400">
|
||||||
|
<Sparkles className="w-8 h-8 mx-auto mb-2 text-gray-300" />
|
||||||
|
<p className="text-xs">选择筛选条件后点击「开始预测」按钮进行AI风险分析</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* === 结构化判赔预测:左右两栏布局 === */}
|
||||||
|
{mode === 'structured' && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4 mb-4">
|
||||||
|
{/* 左栏:表单(占 2/5) */}
|
||||||
|
<div className="space-y-3 lg:col-span-2">
|
||||||
|
{/* 卡片1:员工信息 */}
|
||||||
|
<div className="border border-gray-200 rounded-lg p-3 bg-white">
|
||||||
|
<div className="flex items-center gap-1.5 mb-2.5 text-xs font-semibold text-gray-700">
|
||||||
|
<User className="w-3.5 h-3.5 text-primary" />
|
||||||
|
员工信息
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>争议场景 <span className="text-danger">*</span></Label>
|
||||||
|
<Select value={scenarioType} onChange={(e) => setScenarioType(e.target.value)}>
|
||||||
|
{SCENARIO_TYPES.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>涉及员工</Label>
|
||||||
|
<Select value={structEmployeeName} onChange={(e) => handleStructEmployeeChange(e.target.value)}>
|
||||||
|
<option value="">选择员工(可选)</option>
|
||||||
|
{(employees || []).map((e: any) => <option key={e.id} value={e.name}>{e.name}({e.department})</option>)}
|
||||||
|
</Select>
|
||||||
|
{employeeSpecialStatus && (
|
||||||
|
<div className="mt-1 flex items-center gap-1 text-xs text-amber-700 bg-amber-50 border border-amber-200 px-2 py-1 rounded">
|
||||||
|
<AlertTriangle className="w-3 h-3 flex-shrink-0" />
|
||||||
|
该员工处于:<strong>{employeeSpecialStatus}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
|
||||||
|
<div>
|
||||||
|
<Label className="flex items-center gap-1">
|
||||||
|
所在地区
|
||||||
|
{autoFilledFields.region && <span title="系统带出"><Database className="w-3 h-3 text-primary" /></span>}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="如:北京"
|
||||||
|
value={region}
|
||||||
|
onChange={(e) => { setRegion(e.target.value); setAutoFilledFields((prev) => ({ ...prev, region: false })) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="flex items-center gap-1">
|
||||||
|
员工月薪(元)
|
||||||
|
{autoFilledFields.salary && <span title="系统带出"><Database className="w-3 h-3 text-primary" /></span>}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="如:8000"
|
||||||
|
value={monthlySalary}
|
||||||
|
onChange={(e) => { setMonthlySalary(e.target.value); setAutoFilledFields((prev) => ({ ...prev, salary: false })) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 卡片2:争议事实 */}
|
||||||
|
<div className="border border-gray-200 rounded-lg p-3 bg-white">
|
||||||
|
<div className="flex items-center gap-1.5 mb-2.5 text-xs font-semibold text-gray-700">
|
||||||
|
<FileText className="w-3.5 h-3.5 text-primary" />
|
||||||
|
争议事实
|
||||||
|
</div>
|
||||||
|
<Label className="flex items-center gap-1">
|
||||||
|
违纪/争议事实
|
||||||
|
{autoFilledFields.violationFact && <span title="系统带出,请补充本次争议事实"><Database className="w-3 h-3 text-primary" /></span>}
|
||||||
|
</Label>
|
||||||
|
<textarea
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-xs min-h-[80px] resize-y"
|
||||||
|
placeholder="描述具体的违纪事实或争议情况,例如:员工连续旷工3天,公司拟以严重违纪为由解除劳动合同..."
|
||||||
|
value={violationFact}
|
||||||
|
onChange={(e) => { setViolationFact(e.target.value); setAutoFilledFields((prev) => ({ ...prev, violationFact: false })) }}
|
||||||
|
/>
|
||||||
|
<div className="mt-2">
|
||||||
|
<Label>
|
||||||
|
补充信息(可选)
|
||||||
|
{autoFilledFields.extraInfo && <span title="系统带出"><Database className="w-3 h-3 text-primary inline" /></span>}
|
||||||
|
</Label>
|
||||||
|
<textarea
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-xs min-h-[50px] resize-y"
|
||||||
|
placeholder="其他需要说明的情况,如是否有工会等..."
|
||||||
|
value={extraInfo}
|
||||||
|
onChange={(e) => { setExtraInfo(e.target.value); setAutoFilledFields((prev) => ({ ...prev, extraInfo: false })) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 卡片3:制度合规 */}
|
||||||
|
<div className="border border-gray-200 rounded-lg p-3 bg-white">
|
||||||
|
<div className="flex items-center gap-1.5 mb-2.5 text-xs font-semibold text-gray-700">
|
||||||
|
<Shield className="w-3.5 h-3.5 text-primary" />
|
||||||
|
制度合规
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>制度民主公示状态</Label>
|
||||||
|
<Select value={democracyStatus} onChange={(e) => setDemocracyStatus(e.target.value)}>
|
||||||
|
<option value="">请选择</option>
|
||||||
|
<option value="已履行民主程序并公示">已履行民主程序并公示</option>
|
||||||
|
<option value="已公示但未履行民主程序">已公示但未履行民主程序</option>
|
||||||
|
<option value="未公示未履行民主程序">未公示未履行民主程序</option>
|
||||||
|
<option value="不确定">不确定</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="flex items-center gap-1">
|
||||||
|
违纪记录留痕情况
|
||||||
|
{autoFilledFields.disciplinary && <span title="系统带出"><Database className="w-3 h-3 text-primary" /></span>}
|
||||||
|
</Label>
|
||||||
|
<Select value={disciplinaryRecord} onChange={(e) => { setDisciplinaryRecord(e.target.value); setAutoFilledFields((prev) => ({ ...prev, disciplinary: false })) }}>
|
||||||
|
<option value="">请选择</option>
|
||||||
|
<option value="有书面警告信且员工签收">有书面警告信且员工签收</option>
|
||||||
|
<option value="有书面记录但未签收">有书面记录但未签收</option>
|
||||||
|
<option value="仅有口头警告">仅有口头警告</option>
|
||||||
|
<option value="无任何记录">无任何记录</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button size="sm" onClick={handlePredict} disabled={loading}>
|
||||||
|
{loading ? '分析中...' : result ? '重新预测' : '开始判赔预测'}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-gray-400">AI 分析结果仅供参考,实际以仲裁裁决为准</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右栏:AI 结果(占 3/5) */}
|
||||||
|
<div className="border border-gray-200 rounded-lg p-3 bg-gray-50 flex flex-col lg:col-span-3" style={{ height: 'calc(100vh - 320px)', maxHeight: 'calc(100vh - 320px)' }}>
|
||||||
|
<div className="flex items-center justify-between mb-2.5 flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-semibold text-gray-700">
|
||||||
|
<Sparkles className="w-3.5 h-3.5 text-primary" />
|
||||||
|
AI 分析结果
|
||||||
|
</div>
|
||||||
|
{result && !loading && (
|
||||||
|
<button
|
||||||
|
onClick={handleExportWord}
|
||||||
|
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
导出 Word
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{loading && !result && (
|
||||||
|
<div className="flex items-center gap-2 text-gray-400 py-8">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" /> 正在分析判赔风险...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{result && (
|
||||||
|
<div className="prose prose-sm max-w-none overflow-x-auto
|
||||||
|
prose-headings:text-gray-900 prose-headings:font-semibold
|
||||||
|
prose-h1:text-base prose-h1:mt-4 prose-h1:mb-2
|
||||||
|
prose-h2:text-sm prose-h2:mt-3 prose-h2:mb-2
|
||||||
|
prose-h3:text-sm prose-h3:mt-2 prose-h3:mb-1
|
||||||
|
prose-p:my-2 prose-p:leading-relaxed
|
||||||
|
prose-li:my-0.5 prose-li:leading-relaxed
|
||||||
|
prose-ul:my-2 prose-ol:my-2
|
||||||
|
prose-code:text-pink-600 prose-code:bg-gray-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-xs prose-code:before:content-none prose-code:after:content-none
|
||||||
|
prose-pre:bg-gray-900 prose-pre:text-gray-100 prose-pre:rounded-lg prose-pre:p-3 prose-pre:my-3
|
||||||
|
prose-blockquote:border-l-primary prose-blockquote:bg-primary/5 prose-blockquote:py-1 prose-blockquote:px-3 prose-blockquote:rounded-r prose-blockquote:not-italic
|
||||||
|
prose-table:text-xs prose-table:border-collapse
|
||||||
|
prose-th:bg-gray-100 prose-th:px-2 prose-th:py-1.5 prose-th:font-semibold prose-th:border prose-th:border-gray-300 prose-th:whitespace-nowrap
|
||||||
|
prose-td:px-2 prose-td:py-1.5 prose-td:border prose-td:border-gray-300 prose-td:align-top
|
||||||
|
prose-a:text-primary prose-a:no-underline hover:prose-a:underline
|
||||||
|
prose-strong:text-gray-900
|
||||||
|
prose-hr:border-gray-200 prose-hr:my-4">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{result}</ReactMarkdown>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 animate-spin inline-block text-gray-400 ml-1" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!result && !loading && (
|
||||||
|
<div className="text-center py-12 text-gray-400">
|
||||||
|
<Sparkles className="w-8 h-8 mx-auto mb-2 text-gray-300" />
|
||||||
|
<p className="text-xs">填写争议场景和关键事实后点击「开始判赔预测」</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import { useState, useRef, useCallback } from 'react'
|
||||||
|
import { REVIEW_DOC_TYPES } from './shared'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { FileSearch, Loader2, Trash2, Save, History, FileText } from 'lucide-react'
|
||||||
|
import { aiApi, employeeApi } from '../../lib/api-services'
|
||||||
|
import Card from '../../components/ui/Card'
|
||||||
|
import Button from '../../components/ui/Button'
|
||||||
|
import { Label, Select } from '../../components/ui/Input'
|
||||||
|
import Modal from '../../components/ui/Modal'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/** 解析 markdown 内联格式(**bold** 和 `code`)为 TextRun 数组 */
|
||||||
|
|
||||||
|
|
||||||
|
// 通用 AI 历史记录 hook
|
||||||
|
function useAIHistory(type: 'predict' | 'review' | 'case') {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const queryKey = [`ai-history-${type}`]
|
||||||
|
|
||||||
|
const { data: history } = useQuery<any[]>({
|
||||||
|
queryKey,
|
||||||
|
queryFn: async () => {
|
||||||
|
return await aiApi.conversations(type)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async ({ title, input, result }: { title: string; input: string; result: string }) => {
|
||||||
|
const res = await aiApi.createConversation({
|
||||||
|
title: `${type}:${title}`,
|
||||||
|
messages: [{ role: 'user', content: input }, { role: 'assistant', content: result }],
|
||||||
|
}) as any
|
||||||
|
return res
|
||||||
|
},
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => aiApi.removeConversation(id),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadHistory = useCallback(async (id: string) => {
|
||||||
|
return await aiApi.conversation(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { history, saveMutation, deleteMutation, loadHistory }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用历史记录栏组件
|
||||||
|
function HistoryBar({ history, onLoad, onDelete }: {
|
||||||
|
history: any[]
|
||||||
|
onLoad: (id: string) => void
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="border-b pb-2 max-h-40 overflow-y-auto">
|
||||||
|
{history.length > 0 ? history.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={() => onLoad(c.id)}>
|
||||||
|
{c.title.replace(/^(predict:|review:|case:)/, '')}
|
||||||
|
</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(); onDelete(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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewTab() {
|
||||||
|
const [contractText, setContractText] = useState('')
|
||||||
|
const [result, setResult] = useState<any>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [docType, setDocType] = useState('labor_contract')
|
||||||
|
const [fileName, setFileName] = useState('')
|
||||||
|
const [showSaveModal, setShowSaveModal] = useState(false)
|
||||||
|
const [saveEmployeeId, setSaveEmployeeId] = useState('')
|
||||||
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const { history, saveMutation, deleteMutation, loadHistory } = useAIHistory('review')
|
||||||
|
|
||||||
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const ext = file.name.toLowerCase().split('.').pop()
|
||||||
|
if (ext !== 'docx' && ext !== 'doc') {
|
||||||
|
toast.error('仅支持 .docx 格式文件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (file.size > 100 * 1024 * 1024) {
|
||||||
|
toast.error('文件大小不能超过 100MB')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
const res = await aiApi.reviewUpload(formData) as any
|
||||||
|
if (res?.text) {
|
||||||
|
setContractText(res.data.text)
|
||||||
|
setFileName(file.name)
|
||||||
|
toast.success(`已提取文件内容(${res.data.text.length} 字)`)
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.response?.data?.error?.message || '文件上传失败')
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: employees } = useQuery<any[]>({
|
||||||
|
queryKey: ['employee-list'],
|
||||||
|
queryFn: () => employeeApi.list({ status: 'ACTIVE' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleReview = async () => {
|
||||||
|
if (!contractText.trim()) return
|
||||||
|
setLoading(true)
|
||||||
|
setResult(null)
|
||||||
|
try {
|
||||||
|
const res = await aiApi.review(contractText) as any
|
||||||
|
setResult(res)
|
||||||
|
// 自动保存到历史
|
||||||
|
if (res && !res.error) {
|
||||||
|
const title = contractText.slice(0, 30).replace(/\n/g, ' ')
|
||||||
|
saveMutation.mutate({ title, input: contractText, result: res.data.text || JSON.stringify(res.data) })
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setResult({ error: `出错了:${err.response?.data?.error?.message || '请稍后重试'}` })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoadHistory = async (id: string) => {
|
||||||
|
const data = await loadHistory(id)
|
||||||
|
if (data?.messages) {
|
||||||
|
const userMsg = data.messages.find((m: any) => m.role === 'user')
|
||||||
|
const assistantMsg = data.messages.find((m: any) => m.role === 'assistant')
|
||||||
|
if (userMsg) setContractText(userMsg.content)
|
||||||
|
if (assistantMsg) {
|
||||||
|
try { setResult(JSON.parse(assistantMsg.content)) } catch { setResult({ text: assistantMsg.content }) }
|
||||||
|
}
|
||||||
|
setShowHistory(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!saveEmployeeId || !result) return
|
||||||
|
try {
|
||||||
|
await aiApi.reviewSave({ employeeId: saveEmployeeId, type: 'REVIEW', input: contractText, result: result.text || JSON.stringify(result) })
|
||||||
|
setShowSaveModal(false)
|
||||||
|
setSaveEmployeeId('')
|
||||||
|
toast.success('已保存到员工档案')
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error('保存失败:' + (err.response?.data?.error?.message || '请稍后重试'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelConfig: Record<string, { color: string; bg: string; label: string }> = {
|
||||||
|
RED: { color: 'text-red-600', bg: 'bg-red-50', label: '高风险' },
|
||||||
|
YELLOW: { color: 'text-yellow-600', bg: 'bg-yellow-50', label: '中风险' },
|
||||||
|
GREEN: { color: 'text-green-600', bg: 'bg-green-50', label: '低风险' },
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<FileSearch className="w-5 h-5 text-primary" />
|
||||||
|
<h2 className="text-sm font-medium">合同审查</h2>
|
||||||
|
<Button size="sm" variant="secondary" className="ml-auto" onClick={() => setShowHistory(!showHistory)}><History className="w-4 h-4 mr-1" />历史记录</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showHistory && (
|
||||||
|
<div className="mt-2 mb-3">
|
||||||
|
<HistoryBar history={history || []} onLoad={handleLoadHistory} onDelete={(id) => deleteMutation.mutate(id)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{/* 文件上传区 */}
|
||||||
|
<div>
|
||||||
|
<Label>上传文件审查(可选)</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={docType} onChange={(e) => setDocType(e.target.value)} className="w-40">
|
||||||
|
{REVIEW_DOC_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||||
|
</Select>
|
||||||
|
<input ref={fileInputRef} type="file" accept=".docx,.txt,.pdf" onChange={handleFileUpload} className="hidden" />
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
|
||||||
|
{uploading ? (<><Loader2 className="w-4 h-4 animate-spin mr-1" />提取中...</>) : (<><FileText className="w-4 h-4 mr-1" />上传文件</>)}
|
||||||
|
</Button>
|
||||||
|
{fileName && <span className="text-xs text-gray-500 truncate max-w-[200px]">{fileName}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Label>粘贴或上传合同条款文本</Label>
|
||||||
|
<textarea
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-xs min-h-[200px] resize-y"
|
||||||
|
placeholder="粘贴劳动合同文本..."
|
||||||
|
value={contractText}
|
||||||
|
onChange={(e) => setContractText(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Button onClick={handleReview} disabled={loading || !contractText.trim()}>
|
||||||
|
{loading ? <><Loader2 className="w-4 h-4 animate-spin mr-1" />审查中...</> : '开始审查'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<Card>
|
||||||
|
<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>
|
||||||
|
{result.error ? (
|
||||||
|
<div className="text-xs text-danger">{result.error}</div>
|
||||||
|
) : result.structured ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 合规评分 */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-gray-500">合规评分</span>
|
||||||
|
<span className={`text-lg font-bold ${result.structured.score >= 80 ? 'text-safe' : result.structured.score >= 60 ? 'text-warning' : 'text-danger'}`}>
|
||||||
|
{result.structured.score}/100
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 风险项列表 */}
|
||||||
|
{result.structured.riskItems.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-xs font-medium">风险项({result.structured.riskItems.length})</h4>
|
||||||
|
{result.structured.riskItems.map((item: any, i: number) => {
|
||||||
|
const cfg = levelConfig[item.level] || levelConfig.YELLOW
|
||||||
|
return (
|
||||||
|
<div key={i} className={`rounded-md p-3 ${cfg.bg}`}>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className={`text-xs font-medium ${cfg.color}`}>{cfg.label}</span>
|
||||||
|
<span className="text-xs font-medium">{item.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 mb-1">{item.description}</div>
|
||||||
|
<div className="text-xs text-gray-500">建议:{item.suggestion}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 总体建议 */}
|
||||||
|
{result.structured.summary && (
|
||||||
|
<div className="border-t pt-2">
|
||||||
|
<h4 className="text-xs font-medium mb-1">总体建议</h4>
|
||||||
|
<p className="text-xs text-gray-600">{result.structured.summary}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 原始文本可展开 */}
|
||||||
|
<details className="border-t pt-2">
|
||||||
|
<summary className="text-xs text-gray-400 cursor-pointer">查看原始文本</summary>
|
||||||
|
<div className="text-xs text-gray-700 whitespace-pre-wrap mt-2">{result.text}</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-gray-700 whitespace-pre-wrap">{result.text || JSON.stringify(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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/** 12 类争议场景 */
|
||||||
|
export const SCENARIO_TYPES = [
|
||||||
|
{ value: 'discipline', label: '违纪解除' },
|
||||||
|
{ value: 'incompetence', label: '不胜任解除' },
|
||||||
|
{ value: 'probation', label: '试用期解除' },
|
||||||
|
{ value: 'layoff', label: '经济性裁员' },
|
||||||
|
{ value: 'expiry', label: '合同到期不续签' },
|
||||||
|
{ value: 'negotiated', label: '协商解除' },
|
||||||
|
{ value: 'transfer', label: '调岗调薪争议' },
|
||||||
|
{ value: 'overtime', label: '加班费争议' },
|
||||||
|
{ value: 'injury', label: '工伤待遇争议' },
|
||||||
|
{ value: 'noncompete', label: '竞业限制争议' },
|
||||||
|
{ value: 'confidentiality', label: '保密协议争议' },
|
||||||
|
{ value: 'social_insurance', label: '社保公积金争议' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const REVIEW_DOC_TYPES = [
|
||||||
|
{ value: 'labor_contract', label: '劳动合同' },
|
||||||
|
{ value: 'rescission', label: '协商解除协议' },
|
||||||
|
{ value: 'labor_service', label: '劳务协议' },
|
||||||
|
{ value: 'internship', label: '实习协议' },
|
||||||
|
{ value: 'nda', label: '保密协议' },
|
||||||
|
{ value: 'other', label: '其他' },
|
||||||
|
]
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate, Link } from 'react-router-dom'
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
import { Eye, EyeOff } from 'lucide-react'
|
import { Eye, EyeOff, Shield, Zap, Users, FileText, ArrowRight } from 'lucide-react'
|
||||||
import Logo from '../../components/ui/Logo'
|
import Logo from '../../components/ui/Logo'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
@@ -67,27 +67,99 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{ icon: Users, title: '智能花名册', desc: '员工档案全生命周期管理' },
|
||||||
|
{ icon: FileText, title: '合同自动化', desc: '电子签约 + 到期预警' },
|
||||||
|
{ icon: Shield, title: '合规风控', desc: 'AI 驱动的用工风险检测' },
|
||||||
|
{ icon: Zap, title: '薪税一键算', desc: '工资社保个税自动计算' },
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-surface px-4">
|
<div className="min-h-screen flex bg-surface-page">
|
||||||
|
{/* 左侧品牌展示区 */}
|
||||||
|
<div className="hidden lg:flex lg:w-[480px] xl:w-[540px] relative overflow-hidden bg-gradient-to-br from-brand-700 via-brand-600 to-brand-800">
|
||||||
|
{/* 装饰性几何图形 */}
|
||||||
|
<div className="absolute inset-0 opacity-10">
|
||||||
|
<div className="absolute top-20 left-20 w-72 h-72 rounded-full border-[3px] border-white" />
|
||||||
|
<div className="absolute bottom-32 right-10 w-48 h-48 rounded-full border-[2px] border-white" />
|
||||||
|
<div className="absolute top-1/2 left-1/3 w-96 h-96 rounded-full border-[1px] border-white" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-0 right-0 w-40 h-40 bg-white/5 rounded-bl-[80px]" />
|
||||||
|
<div className="absolute bottom-0 left-0 w-32 h-32 bg-white/5 rounded-tr-[64px]" />
|
||||||
|
|
||||||
|
<div className="relative z-10 flex flex-col justify-between p-12 xl:p-16 text-white w-full">
|
||||||
|
{/* Logo + 品牌名 */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-11 h-11 rounded-xl bg-white/15 backdrop-blur flex items-center justify-center">
|
||||||
|
<Logo className="w-7 h-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xl font-bold tracking-tight">企业用工专家</div>
|
||||||
|
<div className="text-xs text-white/60 mt-0.5">TurboHR · 智能人力资源平台</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 标语 */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl xl:text-4xl font-bold leading-tight tracking-tight">
|
||||||
|
让用工管理<br />简单、合规、高效
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 text-sm text-white/70 leading-relaxed max-w-sm">
|
||||||
|
一站式人力资源数字化解决方案,覆盖员工全生命周期,AI 赋能合规风控
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 功能亮点 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{features.map((f, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 group">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-white/10 backdrop-blur flex items-center justify-center flex-shrink-0 group-hover:bg-white/20 transition-colors">
|
||||||
|
<f.icon className="w-4.5 h-4.5 text-white" strokeWidth={1.8} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-white/95">{f.title}</div>
|
||||||
|
<div className="text-xs text-white/55 mt-0.5">{f.desc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部版权 */}
|
||||||
|
<div className="text-xs text-white/40">
|
||||||
|
© 2026 TurboHR · 企业用工专家
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧登录表单区 */}
|
||||||
|
<div className="flex-1 flex items-center justify-center px-4 py-12">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
<div className="flex items-center justify-center gap-2 mb-8">
|
{/* 移动端 Logo */}
|
||||||
|
<div className="flex lg:hidden items-center justify-center gap-2 mb-8">
|
||||||
<Logo className="w-8 h-8 text-primary" />
|
<Logo className="w-8 h-8 text-primary" />
|
||||||
<span className="text-xl font-bold">企业用工专家</span>
|
<span className="text-xl font-bold">企业用工专家</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
{/* 欢迎语 */}
|
||||||
<h1 className="text-lg font-semibold mb-4">登录</h1>
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">欢迎回来</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1.5">登录您的账户,开始高效管理</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>
|
<div className="mb-5 px-4 py-3 rounded-lg bg-red-50 border border-red-100 text-red-700 text-sm flex items-start gap-2">
|
||||||
|
<span className="flex-shrink-0 mt-0.5">⚠️</span>
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<Label>手机号</Label>
|
<Label>手机号</Label>
|
||||||
<Input
|
<Input
|
||||||
type="tel"
|
type="tel"
|
||||||
placeholder="请输入手机号"
|
placeholder="请输入手机号"
|
||||||
|
className="h-11"
|
||||||
{...register('phone')}
|
{...register('phone')}
|
||||||
maxLength={11}
|
maxLength={11}
|
||||||
/>
|
/>
|
||||||
@@ -100,19 +172,21 @@ export default function Login() {
|
|||||||
<Input
|
<Input
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
placeholder="请输入密码"
|
placeholder="请输入密码"
|
||||||
|
className="h-11 pr-10"
|
||||||
{...register('password')}
|
{...register('password')}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
>
|
>
|
||||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
{showPassword ? <EyeOff className="w-4.5 h-4.5" /> : <Eye className="w-4.5 h-4.5" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{errors.password && <p className="text-xs text-red-500 mt-1">{errors.password.message}</p>}
|
{errors.password && <p className="text-xs text-red-500 mt-1">{errors.password.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -122,16 +196,37 @@ export default function Login() {
|
|||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-500">记住账号密码</span>
|
<span className="text-sm text-gray-500">记住账号密码</span>
|
||||||
</label>
|
</label>
|
||||||
|
<Link to="/forgot-password" className="text-sm text-primary hover:text-primary-dark transition-colors">忘记密码?</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={loading}>
|
<Button type="submit" size="lg" className="w-full h-11" disabled={loading}>
|
||||||
{loading ? '登录中...' : '登录'}
|
{loading ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
登录中...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
登录
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-between text-sm">
|
{/* 分割线 */}
|
||||||
<Link to="/forgot-password" className="text-primary hover:underline">忘记密码?</Link>
|
<div className="mt-8 mb-6 flex items-center gap-4">
|
||||||
<Link to="/register" className="text-primary hover:underline">注册新企业</Link>
|
<div className="flex-1 h-px bg-gray-200" />
|
||||||
|
<span className="text-xs text-gray-400">还没有账户?</span>
|
||||||
|
<div className="flex-1 h-px bg-gray-200" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/register"
|
||||||
|
className="block w-full h-11 leading-[44px] text-center rounded-md border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50 hover:border-gray-400 transition-colors"
|
||||||
|
>
|
||||||
|
注册新企业
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import {
|
|||||||
TrendingDown, Calendar, ChevronRight, Filter,
|
TrendingDown, Calendar, ChevronRight, Filter,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Card from '../../components/ui/Card'
|
import Card from '../../components/ui/Card'
|
||||||
import Button from '../../components/ui/Button'
|
|
||||||
import { InlineAlert } from '../../components/ui/InlineAlert'
|
import { InlineAlert } from '../../components/ui/InlineAlert'
|
||||||
|
import PageGuide from '../../components/ui/PageGuide'
|
||||||
|
import QueryError from '../../components/ui/QueryError'
|
||||||
import { dashboardApi } from '../../lib/api-services'
|
import { dashboardApi } from '../../lib/api-services'
|
||||||
|
|
||||||
/** 风险等级配置 */
|
/** 风险等级配置 */
|
||||||
@@ -33,10 +34,10 @@ const RISK_TYPES: Record<string, { label: string; icon: typeof ShieldAlert; link
|
|||||||
|
|
||||||
export default function RiskCenter() {
|
export default function RiskCenter() {
|
||||||
const [filterLevel, setFilterLevel] = useState<string>('ALL')
|
const [filterLevel, setFilterLevel] = useState<string>('ALL')
|
||||||
const [filterType, setFilterType] = useState<string>('ALL')
|
const [filterType] = useState<string>('ALL')
|
||||||
|
|
||||||
/** 获取风险列表 */
|
/** 获取风险列表 */
|
||||||
const { data: risks = [], isLoading } = useQuery<any[]>({
|
const { data: risks = [], isLoading, isError, error, refetch } = useQuery<any[]>({
|
||||||
queryKey: ['risk-center'],
|
queryKey: ['risk-center'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return await dashboardApi.risks()
|
return await dashboardApi.risks()
|
||||||
@@ -71,6 +72,9 @@ export default function RiskCenter() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<PageGuide>
|
||||||
|
统一风险中心汇总展示合同到期、薪酬异常、社保漏缴、合规风险等各类人力资源风险。按风险等级(高/中/低)分类展示,支持筛选和快速跳转处理。建议定期查看以及时发现和处置风险。
|
||||||
|
</PageGuide>
|
||||||
{/* 页头 */}
|
{/* 页头 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -163,6 +167,8 @@ export default function RiskCenter() {
|
|||||||
<Card>
|
<Card>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||||
|
) : isError ? (
|
||||||
|
<QueryError error={error} onRetry={refetch} />
|
||||||
) : filteredRisks.length === 0 ? (
|
) : filteredRisks.length === 0 ? (
|
||||||
<div className="text-center py-8 text-gray-400 text-sm">
|
<div className="text-center py-8 text-gray-400 text-sm">
|
||||||
{risks.length === 0 ? '暂无风险项,一切正常' : '当前筛选条件下无匹配项'}
|
{risks.length === 0 ? '暂无风险项,一切正常' : '当前筛选条件下无匹配项'}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, BarChart, Bar, XAxis, YAxis, CartesianGrid, Legend } from 'recharts'
|
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts'
|
||||||
import { Award, TrendingUp } from 'lucide-react'
|
import { Award, TrendingUp } from 'lucide-react'
|
||||||
import { dashboardApi } from '../../lib/api-services'
|
import { dashboardApi } from '../../lib/api-services'
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,454 @@
|
|||||||
|
import { useState, useRef } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Info, Check, Upload, Settings as SettingsIcon, FileText, X } from 'lucide-react'
|
||||||
|
import PageGuide from '../../components/ui/PageGuide'
|
||||||
|
import { payrollApi, employeeApi } from '../../lib/api-services'
|
||||||
|
import Card from '../../components/ui/Card'
|
||||||
|
import Button from '../../components/ui/Button'
|
||||||
|
import { Input, Label } from '../../components/ui/Input'
|
||||||
|
// 金额格式化:保留两位小数 + 千分位
|
||||||
|
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
|
||||||
|
export function OvertimeCalculator() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [step, setStep] = useState<1 | 2 | 3>(1)
|
||||||
|
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>({
|
||||||
|
queryKey: ['overtime-config'],
|
||||||
|
queryFn: async () => {
|
||||||
|
return await payrollApi.overtimeConfig()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveConfigMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => payrollApi.saveOvertimeConfig(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['overtime-config'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 员工列表
|
||||||
|
const { data: employees } = useQuery<{ items: { id: string; name: string; department: string }[] }>({
|
||||||
|
queryKey: ['employees-for-overtime'],
|
||||||
|
queryFn: async () => {
|
||||||
|
return await employeeApi.paged({ pageSize: 100 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加班记录
|
||||||
|
const { data: overtimeRecords, refetch } = useQuery<any[]>({
|
||||||
|
queryKey: ['overtime-records', month],
|
||||||
|
queryFn: async () => {
|
||||||
|
return await payrollApi.overtimeRecords({ month })
|
||||||
|
},
|
||||||
|
enabled: step === 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
const batchImportMutation = useMutation({
|
||||||
|
mutationFn: (data: any[]) => payrollApi.batchImportOvertime(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['overtime-records'] })
|
||||||
|
setPreviewData([])
|
||||||
|
setStep(3)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新单条加班记录
|
||||||
|
const updateOvertimeMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: any }) =>
|
||||||
|
payrollApi.updateOvertime(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
|
||||||
|
const empList = employees?.items || []
|
||||||
|
const fileName = file.name.toLowerCase()
|
||||||
|
|
||||||
|
const parseRows = (rows: any[]): void => {
|
||||||
|
const items: any[] = []
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const row = rows[i]
|
||||||
|
// 兼容中文列名和英文列名
|
||||||
|
const empName = String(row['姓名'] ?? row['name'] ?? row['姓名*'] ?? '').trim()
|
||||||
|
if (!empName) continue
|
||||||
|
const emp = empList.find(e => e.name === empName)
|
||||||
|
if (!emp) continue
|
||||||
|
items.push({
|
||||||
|
employeeId: emp.id,
|
||||||
|
employeeName: emp.name,
|
||||||
|
department: emp.department,
|
||||||
|
month: String(row['月份'] ?? row['month'] ?? '').trim() || month,
|
||||||
|
weekdayHours: Number(row['工作日加班时长'] ?? row['weekdayHours'] ?? row['工作日'] ?? 0) || 0,
|
||||||
|
weekendHours: Number(row['休息日加班时长'] ?? row['weekendHours'] ?? row['休息日'] ?? 0) || 0,
|
||||||
|
holidayHours: Number(row['法定节假日加班时长'] ?? row['holidayHours'] ?? row['法定节假日'] ?? 0) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (items.length > 0) {
|
||||||
|
setPreviewData(items)
|
||||||
|
} else {
|
||||||
|
toast.error('未匹配到员工,请确保文件包含"姓名"列')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls')) {
|
||||||
|
// Excel 格式解析
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = async (event) => {
|
||||||
|
try {
|
||||||
|
const data = new Uint8Array(event.target?.result as ArrayBuffer)
|
||||||
|
const XLSX = await import('xlsx')
|
||||||
|
const wb = XLSX.read(data, { type: 'array' })
|
||||||
|
const ws = wb.Sheets[wb.SheetNames[0]]
|
||||||
|
const rows = XLSX.utils.sheet_to_json(ws)
|
||||||
|
parseRows(rows)
|
||||||
|
} catch {
|
||||||
|
toast.error('Excel 文件解析失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsArrayBuffer(file)
|
||||||
|
} else {
|
||||||
|
// CSV 格式解析(保持兼容)
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const text = event.target?.result as string
|
||||||
|
const lines = text.split('\n').filter(l => l.trim())
|
||||||
|
if (lines.length < 2) { toast.error('CSV 文件内容为空'); return }
|
||||||
|
// 解析表头
|
||||||
|
const headers = lines[0].split(',').map(c => c.trim())
|
||||||
|
const rows: any[] = []
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const cols = lines[i].split(',').map(c => c.trim())
|
||||||
|
const row: any = {}
|
||||||
|
headers.forEach((h, idx) => { row[h] = cols[idx] ?? '' })
|
||||||
|
rows.push(row)
|
||||||
|
}
|
||||||
|
parseRows(rows)
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmImport = () => {
|
||||||
|
const payload = previewData.map(d => ({
|
||||||
|
employeeId: d.employeeId,
|
||||||
|
month: d.month,
|
||||||
|
weekdayHours: d.weekdayHours,
|
||||||
|
weekendHours: d.weekendHours,
|
||||||
|
holidayHours: d.holidayHours,
|
||||||
|
}))
|
||||||
|
batchImportMutation.mutate(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = config || { weekdayRate: 1.5, weekendRate: 2.0, holidayRate: 3.0, monthlyDays: 21.75, dailyHours: 8 }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageGuide>
|
||||||
|
加班费计算器根据加班类型(工作日/周末/法定节假日)和对应倍率自动计算加班费。流程:①选择月份和员工 → ②录入加班时长 → ③系统按配置倍率计算费用 → ④确认后计入当月工资。
|
||||||
|
</PageGuide>
|
||||||
|
{/* 步骤指示器 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{[
|
||||||
|
{ n: 1, label: '设定计算规则' },
|
||||||
|
{ n: 2, label: '导入考勤数据' },
|
||||||
|
{ n: 3, label: '查看加班记录' },
|
||||||
|
].map((s) => (
|
||||||
|
<div key={s.n} className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setStep(s.n as 1 | 2 | 3)}
|
||||||
|
className={`px-3 py-1.5 rounded text-xs flex items-center gap-1.5 transition-colors ${
|
||||||
|
step === s.n ? 'bg-primary text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`w-4 h-4 rounded-full flex items-center justify-center text-xs ${
|
||||||
|
step === s.n ? 'bg-white/20' : step > s.n ? 'bg-safe text-white' : 'bg-gray-300 text-white'
|
||||||
|
}`}>{step > s.n ? '✓' : s.n}</span>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
{s.n < 3 && <div className="w-4 h-px bg-gray-300" />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 1: 设定计算规则 */}
|
||||||
|
{step === 1 && (
|
||||||
|
<Card>
|
||||||
|
<h2 className="text-xs font-medium mb-3 flex items-center gap-2"><SettingsIcon className="w-4 h-4" />加班费计算规则</h2>
|
||||||
|
{configLoading ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>工作日加班倍率</Label>
|
||||||
|
<Input type="number" step="0.1" defaultValue={cfg.weekdayRate} onBlur={(e) => saveConfigMutation.mutate({ ...cfg, weekdayRate: Number(e.target.value) })} />
|
||||||
|
<p className="text-xs text-gray-500 mt-1">平时加班按小时工资的倍率</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>休息日加班倍率</Label>
|
||||||
|
<Input type="number" step="0.1" defaultValue={cfg.weekendRate} onBlur={(e) => saveConfigMutation.mutate({ ...cfg, weekendRate: Number(e.target.value) })} />
|
||||||
|
<p className="text-xs text-gray-500 mt-1">周末加班按小时工资的倍率</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>法定节假日加班倍率</Label>
|
||||||
|
<Input type="number" step="0.1" defaultValue={cfg.holidayRate} onBlur={(e) => saveConfigMutation.mutate({ ...cfg, holidayRate: Number(e.target.value) })} />
|
||||||
|
<p className="text-xs text-gray-500 mt-1">法定节假日加班按小时工资的倍率</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>月计薪天数</Label>
|
||||||
|
<Input type="number" step="0.01" defaultValue={cfg.monthlyDays} onBlur={(e) => saveConfigMutation.mutate({ ...cfg, monthlyDays: Number(e.target.value) })} />
|
||||||
|
<p className="text-xs text-gray-500 mt-1">用于折算日工资/小时工资(默认21.75天)</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>每日工时(小时)</Label>
|
||||||
|
<Input type="number" step="0.5" defaultValue={cfg.dailyHours} onBlur={(e) => saveConfigMutation.mutate({ ...cfg, dailyHours: Number(e.target.value) })} />
|
||||||
|
<p className="text-xs text-gray-500 mt-1">用于折算小时工资(默认8小时)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-start gap-2">
|
||||||
|
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p>计算公式:小时工资 = 月工资 ÷ 月计薪天数 ÷ 每日工时</p>
|
||||||
|
<p>加班费 = 小时工资 × 倍率 × 加班工时</p>
|
||||||
|
<p className="mt-1 text-gray-500">实际金额在发薪批次中根据员工月工资自动计算,此处只配置倍率规则。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{saveConfigMutation.isSuccess && (
|
||||||
|
<div className="text-xs text-safe flex items-center gap-1"><Check className="w-3.5 h-3.5" />规则已保存</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={() => setStep(2)}>下一步:导入考勤数据 →</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: 导入考勤数据 */}
|
||||||
|
{step === 2 && (
|
||||||
|
<Card>
|
||||||
|
<h2 className="text-xs font-medium mb-3 flex items-center gap-2"><Upload className="w-4 h-4" />导入加班工时</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>月份</Label>
|
||||||
|
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-48" />
|
||||||
|
</div>
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<input ref={fileInputRef} type="file" accept=".csv" className="hidden" onChange={handleFileUpload} />
|
||||||
|
<Button variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={batchImportMutation.isPending}>
|
||||||
|
<Upload className="w-4 h-4 mr-1" />
|
||||||
|
{batchImportMutation.isPending ? '导入中...' : '选择CSV文件'}
|
||||||
|
</Button>
|
||||||
|
<div className="text-xs text-gray-500 mt-2">
|
||||||
|
CSV格式:姓名,工作日加班(h),休息日加班(h),节假日加班(h),月份(可选)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{previewData.length > 0 && (
|
||||||
|
<div className="border-t pt-3 space-y-3">
|
||||||
|
<div className="text-xs font-medium text-gray-700">预览({previewData.length}条)</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-xs text-gray-500">
|
||||||
|
<th className="py-2 text-left">员工</th>
|
||||||
|
<th className="py-2 text-left">部门</th>
|
||||||
|
<th className="py-2 text-right">工作日(h)</th>
|
||||||
|
<th className="py-2 text-right">休息日(h)</th>
|
||||||
|
<th className="py-2 text-right">节假日(h)</th>
|
||||||
|
<th className="py-2 text-left">月份</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{previewData.map((d, i) => (
|
||||||
|
<tr key={i} className="border-b last:border-0">
|
||||||
|
<td className="py-2">{d.employeeName}</td>
|
||||||
|
<td className="py-2 text-gray-500">{d.department}</td>
|
||||||
|
<td className="py-2 text-right">{d.weekdayHours}</td>
|
||||||
|
<td className="py-2 text-right">{d.weekendHours}</td>
|
||||||
|
<td className="py-2 text-right">{d.holidayHours}</td>
|
||||||
|
<td className="py-2 text-gray-500">{d.month}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={confirmImport} disabled={batchImportMutation.isPending}>
|
||||||
|
{batchImportMutation.isPending ? '保存中...' : '确认导入'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => setPreviewData([])}>取消</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between border-t pt-3">
|
||||||
|
<Button variant="secondary" onClick={() => setStep(1)}>← 上一步</Button>
|
||||||
|
<Button onClick={() => setStep(3)}>下一步:查看加班记录 →</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: 查看加班记录 */}
|
||||||
|
{step === 3 && (
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-xs font-medium flex items-center gap-2"><FileText className="w-4 h-4" />加班记录({month})</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-32" />
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => refetch()}>刷新</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!overtimeRecords || overtimeRecords.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">该月暂无加班记录,请先导入考勤数据</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-xs text-gray-500">
|
||||||
|
<th className="py-2 text-left">员工</th>
|
||||||
|
<th className="py-2 text-left">部门</th>
|
||||||
|
<th className="py-2 text-right">工作日(h)</th>
|
||||||
|
<th className="py-2 text-right">休息日(h)</th>
|
||||||
|
<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>
|
||||||
|
{overtimeRecords.map((r: any) => (
|
||||||
|
<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>
|
||||||
|
{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-500">待计算</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-center">
|
||||||
|
{r.batchId ? (
|
||||||
|
<span className="px-2 py-0.5 rounded bg-green-50 text-safe text-xs">已入批次</span>
|
||||||
|
) : (
|
||||||
|
<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-500 hover:text-gray-600"
|
||||||
|
title="取消"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
!r.batchId && (
|
||||||
|
<button
|
||||||
|
onClick={() => startEdit(r)}
|
||||||
|
className="text-gray-500 hover:text-blue-600"
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between border-t pt-3 mt-3">
|
||||||
|
<Button variant="secondary" onClick={() => setStep(2)}>← 上一步</Button>
|
||||||
|
<div className="text-xs text-gray-500 flex items-center gap-1">
|
||||||
|
<Info className="w-3.5 h-3.5" />
|
||||||
|
加班费金额在发薪批次中「导入加班费」时自动计算
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useConfirm } from '../../hooks/useConfirm'
|
||||||
|
import { Calculator, Check, Layers, Settings as X } from 'lucide-react'
|
||||||
|
import PageGuide from '../../components/ui/PageGuide'
|
||||||
|
import { payrollApi } from '../../lib/api-services'
|
||||||
|
import Card from '../../components/ui/Card'
|
||||||
|
import Button from '../../components/ui/Button'
|
||||||
|
import { Input, Label } from '../../components/ui/Input'
|
||||||
|
import Modal from '../../components/ui/Modal'
|
||||||
|
import Pagination from '../../components/ui/Pagination'
|
||||||
|
// 金额格式化:保留两位小数 + 千分位
|
||||||
|
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
|
||||||
|
export function PayslipManager() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
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 [previewData, setPreviewData] = useState({
|
||||||
|
baseSalary: 0,
|
||||||
|
overtimePay: 0,
|
||||||
|
allowance: 0,
|
||||||
|
deduction: 0,
|
||||||
|
bonus: 0,
|
||||||
|
specialDeduction: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: payslips, isLoading } = useQuery<any[]>({
|
||||||
|
queryKey: ['payslips', month],
|
||||||
|
queryFn: async () => {
|
||||||
|
return await payrollApi.payslips({ month })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => payrollApi.removePayslip(id),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['payslips'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const generateFromBatchMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => payrollApi.generatePayslips(data?.month || data),
|
||||||
|
onSuccess: (res: any) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['payslips'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||||
|
const n = res?.data?.generated || 0
|
||||||
|
toast.success(`已从归档批次汇总生成 ${n} 条工资条并发布。`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const taxPreviewMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => payrollApi.taxPreview(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
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<PageGuide>
|
||||||
|
工资条管理用于查看、编辑和发布已生成的工资明细。支持按月份筛选、批量确认发送、导出个人工资条PDF。员工可在手机端查看已确认的工资条。
|
||||||
|
</PageGuide>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-48" />
|
||||||
|
{payslips && payslips.length > 0 && (
|
||||||
|
<div className="flex gap-2 text-xs">
|
||||||
|
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600">共 {payslips.length} 条</span>
|
||||||
|
<span className="px-2 py-0.5 rounded bg-green-50 text-safe">已确认 {confirmedCount}</span>
|
||||||
|
<span className="px-2 py-0.5 rounded bg-amber-50 text-warning">未确认 {unconfirmedCount}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowTaxPreview(true)}
|
||||||
|
>
|
||||||
|
<Calculator className="w-4 h-4 mr-1" />
|
||||||
|
税率试算
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
if (await confirm({ title: '生成工资条', message: `确认从 ${month} 已归档批次汇总生成工资条?这将覆盖已有的工资条数据。`, variant: 'primary' })) {
|
||||||
|
generateFromBatchMutation.mutate({ month })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={generateFromBatchMutation.isPending}
|
||||||
|
>
|
||||||
|
<Layers className="w-4 h-4 mr-1" />
|
||||||
|
{generateFromBatchMutation.isPending ? '生成中...' : '从批次汇总生成'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||||||
|
) : !payslips || payslips.length === 0 ? (
|
||||||
|
<Card><div className="text-center py-8 text-gray-500">该月份暂无工资条记录</div></Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<Pagination page={page} pageSize={pageSize} total={payslips.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-xs text-gray-500">
|
||||||
|
<th className="py-2 px-2">员工</th>
|
||||||
|
<th className="py-2 px-2">部门</th>
|
||||||
|
<th className="py-2 px-2 text-right">基本工资</th>
|
||||||
|
<th className="py-2 px-2 text-right">加班费</th>
|
||||||
|
<th className="py-2 px-2 text-right">津贴</th>
|
||||||
|
<th className="py-2 px-2 text-right">奖金</th>
|
||||||
|
<th className="py-2 px-2 text-right">扣款</th>
|
||||||
|
<th className="py-2 px-2 text-right">应发合计</th>
|
||||||
|
<th className="py-2 px-2 text-right">个税</th>
|
||||||
|
<th className="py-2 px-2 text-right">实发</th>
|
||||||
|
<th className="py-2 px-2 text-center">确认状态</th>
|
||||||
|
<th className="py-2 px-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{payslips.slice((page - 1) * pageSize, page * pageSize).map((p: any) => (
|
||||||
|
<tr key={p.id} className="border-b last:border-0 hover:bg-gray-50">
|
||||||
|
<td className="py-2 px-2 text-sm font-medium">{p.employee?.name}</td>
|
||||||
|
<td className="py-2 px-2 text-gray-500">{p.employee?.department}</td>
|
||||||
|
<td className="py-2 px-2 text-right">¥{fmt(p.baseSalary)}</td>
|
||||||
|
<td className="py-2 px-2 text-right">¥{fmt(p.overtimePay)}</td>
|
||||||
|
<td className="py-2 px-2 text-right">¥{fmt(p.allowance)}</td>
|
||||||
|
<td className="py-2 px-2 text-right">¥{fmt(p.bonus)}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-danger">{p.deduction > 0 ? '-¥' + fmt(p.deduction) : '¥0'}</td>
|
||||||
|
<td className="py-2 px-2 text-right font-medium text-primary">¥{fmt(p.totalPay)}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-danger">¥{fmt(p.tax)}</td>
|
||||||
|
<td className="py-2 px-2 text-right font-bold text-safe">¥{fmt(p.netPay)}</td>
|
||||||
|
<td className="py-2 px-2 text-center">
|
||||||
|
{p.confirmedAt ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-safe text-xs">
|
||||||
|
<Check className="w-3 h-3" />已确认
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-warning text-xs">未确认</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2">
|
||||||
|
<button
|
||||||
|
onClick={() => deleteMutation.mutate(p.id)}
|
||||||
|
className="text-xs text-gray-500 hover:text-danger"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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-500 hover:text-gray-600">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>基本工资</Label>
|
||||||
|
<Input type="number" value={previewData.baseSalary || ''} onChange={(e) => setPreviewData({ ...previewData, baseSalary: Number(e.target.value) })} placeholder="请输入" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>加班费</Label>
|
||||||
|
<Input type="number" value={previewData.overtimePay || ''} onChange={(e) => setPreviewData({ ...previewData, overtimePay: Number(e.target.value) })} placeholder="请输入" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>津贴</Label>
|
||||||
|
<Input type="number" value={previewData.allowance || ''} onChange={(e) => setPreviewData({ ...previewData, allowance: Number(e.target.value) })} placeholder="请输入" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>奖金</Label>
|
||||||
|
<Input type="number" value={previewData.bonus || ''} onChange={(e) => setPreviewData({ ...previewData, bonus: Number(e.target.value) })} placeholder="请输入" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>扣款</Label>
|
||||||
|
<Input type="number" value={previewData.deduction || ''} onChange={(e) => setPreviewData({ ...previewData, deduction: Number(e.target.value) })} placeholder="请输入" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>专项附加扣除</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-500 mt-2">注:已累计{taxResult.ytdPayslipCount}条工资条计算个税</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useConfirm } from '../../hooks/useConfirm'
|
||||||
|
import { Settings as Plus, X } from 'lucide-react'
|
||||||
|
import PageGuide from '../../components/ui/PageGuide'
|
||||||
|
import { payrollApi } from '../../lib/api-services'
|
||||||
|
import Card from '../../components/ui/Card'
|
||||||
|
import Button from '../../components/ui/Button'
|
||||||
|
import { Input, Label, Select } from '../../components/ui/Input'
|
||||||
|
import Modal from '../../components/ui/Modal'
|
||||||
|
// 金额格式化:保留两位小数 + 千分位
|
||||||
|
|
||||||
|
// ========== 薪酬模版管理 ==========
|
||||||
|
|
||||||
|
export function TemplateManager() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [editingItem, setEditingItem] = useState<any>(null)
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: '',
|
||||||
|
code: '',
|
||||||
|
type: 'INPUT' as 'INPUT' | 'CALCULATED',
|
||||||
|
formula: '',
|
||||||
|
order: 99,
|
||||||
|
isEditable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: items, isLoading } = useQuery<any[]>({
|
||||||
|
queryKey: ['payslip-template'],
|
||||||
|
queryFn: async () => {
|
||||||
|
return await payrollApi.template()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => payrollApi.removeTemplateItem(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['payslip-template'] })
|
||||||
|
toast.success('已删除')
|
||||||
|
},
|
||||||
|
onError: () => toast.error('删除失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => payrollApi.createTemplateItem(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['payslip-template'] })
|
||||||
|
setShowForm(false)
|
||||||
|
toast.success('已新增薪酬项')
|
||||||
|
},
|
||||||
|
onError: () => toast.error('新增失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: any }) => payrollApi.updateTemplateItem(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['payslip-template'] })
|
||||||
|
setShowForm(false)
|
||||||
|
setEditingItem(null)
|
||||||
|
toast.success('已更新')
|
||||||
|
},
|
||||||
|
onError: () => toast.error('更新失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 打开新增表单 */
|
||||||
|
const handleAdd = () => {
|
||||||
|
setEditingItem(null)
|
||||||
|
setForm({ name: '', code: '', type: 'INPUT', formula: '', order: items?.length ? items.length + 1 : 99, isEditable: true })
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开编辑表单 */
|
||||||
|
const handleEdit = (item: any) => {
|
||||||
|
setEditingItem(item)
|
||||||
|
setForm({
|
||||||
|
name: item.name,
|
||||||
|
code: item.code,
|
||||||
|
type: item.type,
|
||||||
|
formula: item.formula || '',
|
||||||
|
order: item.order,
|
||||||
|
isEditable: item.isEditable,
|
||||||
|
})
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交表单 */
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!form.name.trim() || !form.code.trim()) {
|
||||||
|
toast.error('名称和字段代码不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
type: form.type,
|
||||||
|
formula: form.type === 'CALCULATED' ? form.formula.trim() || null : null,
|
||||||
|
order: Number(form.order),
|
||||||
|
isEditable: form.isEditable,
|
||||||
|
}
|
||||||
|
if (editingItem) {
|
||||||
|
updateMutation.mutate({ id: editingItem.id, data: payload })
|
||||||
|
} else {
|
||||||
|
createMutation.mutate({ ...payload, code: form.code.trim() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<PageGuide>
|
||||||
|
薪酬模版用于定义发薪项目结构(基本工资、绩效奖金、补贴、扣款等)。创建模版后可在发薪批次中快速套用,避免逐项配置。支持设置计算公式和适用范围。
|
||||||
|
</PageGuide>
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-medium">薪酬结构模版</h2>
|
||||||
|
<span className="text-xs text-gray-500">定义薪酬项和计算关系</span>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={handleAdd}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />新增薪酬项
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-4 text-gray-500">加载中...</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-xs text-gray-500">
|
||||||
|
<th className="py-2 px-2">序号</th>
|
||||||
|
<th className="py-2 px-2">名称</th>
|
||||||
|
<th className="py-2 px-2">字段代码</th>
|
||||||
|
<th className="py-2 px-2">类型</th>
|
||||||
|
<th className="py-2 px-2">计算公式</th>
|
||||||
|
<th className="py-2 px-2">可编辑</th>
|
||||||
|
<th className="py-2 px-2 text-right">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items?.map((item: any) => (
|
||||||
|
<tr key={item.id} className="border-b last:border-0 hover:bg-gray-50">
|
||||||
|
<td className="py-2 px-2 text-gray-500">{item.order}</td>
|
||||||
|
<td className="py-2 px-2 text-sm font-medium">{item.name}</td>
|
||||||
|
<td className="py-2 px-2 text-gray-500 font-mono">{item.code}</td>
|
||||||
|
<td className="py-2 px-2">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs ${item.type === 'INPUT' ? 'bg-blue-50 text-blue-600' : 'bg-purple-50 text-purple-600'}`}>
|
||||||
|
{item.type === 'INPUT' ? '输入项' : '计算项'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2 text-gray-500 font-mono">{item.formula || '—'}</td>
|
||||||
|
<td className="py-2 px-2">
|
||||||
|
<span className={`text-xs ${item.isEditable ? 'text-safe' : 'text-gray-500'}`}>
|
||||||
|
{item.isEditable ? '可编辑' : '不可编辑'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(item)}
|
||||||
|
className="text-xs text-gray-500 hover:text-primary"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
{!item.isDefault && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (await confirm({ title: '删除薪酬项', message: `确认删除薪酬项「${item.name}」?` })) {
|
||||||
|
deleteMutation.mutate(item.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-xs text-gray-500 hover:text-danger"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{item.isDefault && <span className="text-xs text-gray-300">预置</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500 mt-3">
|
||||||
|
预置项为系统默认薪酬结构,不可删除。计算项的公式支持引用其他字段进行自动计算。
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 新增/编辑弹窗 */}
|
||||||
|
{showForm && (
|
||||||
|
<Modal open onClose={() => { setShowForm(false); setEditingItem(null) }}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-medium">{editingItem ? '编辑薪酬项' : '新增薪酬项'}</h3>
|
||||||
|
<button onClick={() => { setShowForm(false); setEditingItem(null) }} className="text-gray-500 hover:text-gray-700">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label>名称</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
placeholder="如:交通补贴"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>字段代码</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={form.code}
|
||||||
|
onChange={(e) => setForm({ ...form, code: e.target.value })}
|
||||||
|
placeholder="如:transportAllowance"
|
||||||
|
disabled={!!editingItem}
|
||||||
|
/>
|
||||||
|
{editingItem && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">字段代码创建后不可修改</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>类型</Label>
|
||||||
|
<Select
|
||||||
|
value={form.type}
|
||||||
|
onChange={(e) => setForm({ ...form, type: e.target.value as 'INPUT' | 'CALCULATED' })}
|
||||||
|
disabled={!!editingItem}
|
||||||
|
>
|
||||||
|
<option value="INPUT">输入项(手动填写)</option>
|
||||||
|
<option value="CALCULATED">计算项(公式自动计算)</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>排序</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={form.order}
|
||||||
|
onChange={(e) => setForm({ ...form, order: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{form.type === 'CALCULATED' && (
|
||||||
|
<div>
|
||||||
|
<Label>计算公式</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={form.formula}
|
||||||
|
onChange={(e) => setForm({ ...form, formula: e.target.value })}
|
||||||
|
placeholder="如:baseSalary + overtimePay + allowance - deduction"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">可引用其他字段代码进行加减乘除运算</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.isEditable}
|
||||||
|
onChange={(e) => setForm({ ...form, isEditable: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">发薪批次中可手动编辑</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={createMutation.isPending || updateMutation.isPending}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{createMutation.isPending || updateMutation.isPending ? '保存中...' : editingItem ? '保存修改' : '确认新增'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => { setShowForm(false); setEditingItem(null) }}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
* 企业租户管理页 — 列表、搜索、查看详情、编辑套餐、删除
|
* 企业租户管理页 — 列表、搜索、查看详情、编辑套餐、删除
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Search, Building2, Eye, Trash2, Edit2, Plus } from 'lucide-react'
|
import { Search, Trash2, Edit2, Plus } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
import { platformApi } from '../../lib/api-services'
|
import { platformApi } from '../../lib/api-services'
|
||||||
import { Input, Select, Label } from '../../components/ui/Input'
|
import { Input, Select, Label } from '../../components/ui/Input'
|
||||||
import Button from '../../components/ui/Button'
|
import Button from '../../components/ui/Button'
|
||||||
@@ -36,7 +37,6 @@ export default function PlatformOrgs() {
|
|||||||
})
|
})
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [editAdmin, setEditAdmin] = useState({ name: '', phone: '', password: '' })
|
const [editAdmin, setEditAdmin] = useState({ name: '', phone: '', password: '' })
|
||||||
const [savingAdmin, setSavingAdmin] = useState(false)
|
|
||||||
|
|
||||||
const fetchOrgs = async () => {
|
const fetchOrgs = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -94,13 +94,13 @@ export default function PlatformOrgs() {
|
|||||||
setEditOrg(null)
|
setEditOrg(null)
|
||||||
fetchOrgs()
|
fetchOrgs()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.response?.data?.error?.message || '保存失败')
|
toast.error(err.response?.data?.error?.message || '保存失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!createForm.name || !createForm.adminPhone || !createForm.adminPassword) {
|
if (!createForm.name || !createForm.adminPhone || !createForm.adminPassword) {
|
||||||
alert('企业名称、管理员手机号、密码不能为空')
|
toast.error('企业名称、管理员手机号、密码不能为空')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setCreating(true)
|
setCreating(true)
|
||||||
@@ -114,7 +114,7 @@ export default function PlatformOrgs() {
|
|||||||
})
|
})
|
||||||
fetchOrgs()
|
fetchOrgs()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.response?.data?.error?.message || '创建失败')
|
toast.error(err.response?.data?.error?.message || '创建失败')
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false)
|
setCreating(false)
|
||||||
}
|
}
|
||||||
@@ -127,7 +127,7 @@ export default function PlatformOrgs() {
|
|||||||
setDeleteOrg(null)
|
setDeleteOrg(null)
|
||||||
fetchOrgs()
|
fetchOrgs()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.response?.data?.error?.message || '删除失败')
|
toast.error(err.response?.data?.error?.message || '删除失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Search, Ban, CheckCircle } from 'lucide-react'
|
import { Search, Ban, CheckCircle } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
import { platformApi } from '../../lib/api-services'
|
import { platformApi } from '../../lib/api-services'
|
||||||
import { Input, Select } from '../../components/ui/Input'
|
import { Input, Select } from '../../components/ui/Input'
|
||||||
import Button from '../../components/ui/Button'
|
import Button from '../../components/ui/Button'
|
||||||
@@ -59,7 +60,7 @@ export default function PlatformUsers() {
|
|||||||
await platformApi.toggleUser(user.id)
|
await platformApi.toggleUser(user.id)
|
||||||
fetchUsers()
|
fetchUsers()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.response?.data?.error?.message || '操作失败')
|
toast.error(err.response?.data?.error?.message || '操作失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,11 @@
|
|||||||
* 员工 Hub 首页 — 员工端统一入口
|
* 员工 Hub 首页 — 员工端统一入口
|
||||||
* 展示个人概览、待办事项、快捷入口、公司公告
|
* 展示个人概览、待办事项、快捷入口、公司公告
|
||||||
*/
|
*/
|
||||||
import { useState } from 'react'
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
DollarSign, FileText, CalendarCheck, ScrollText,
|
DollarSign, FileText, CalendarCheck, ScrollText, CalendarClock,
|
||||||
TrendingUp, Clock, AlertCircle, ChevronRight,
|
AlertCircle, ChevronRight,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { portalApi } from '../../lib/api-services'
|
import { portalApi } from '../../lib/api-services'
|
||||||
import Card from '../../components/ui/Card'
|
import Card from '../../components/ui/Card'
|
||||||
@@ -21,6 +20,7 @@ const QUICK_ACTIONS = [
|
|||||||
{ path: '/portal/payslip', label: '工资条', icon: DollarSign, color: 'bg-emerald-50 text-emerald-600' },
|
{ path: '/portal/payslip', label: '工资条', icon: DollarSign, color: 'bg-emerald-50 text-emerald-600' },
|
||||||
{ path: '/portal/contract', label: '我的合同', icon: FileText, color: 'bg-blue-50 text-blue-600' },
|
{ path: '/portal/contract', label: '我的合同', icon: FileText, color: 'bg-blue-50 text-blue-600' },
|
||||||
{ path: '/portal/attendance', label: '我的考勤', icon: CalendarCheck, color: 'bg-purple-50 text-purple-600' },
|
{ path: '/portal/attendance', label: '我的考勤', icon: CalendarCheck, color: 'bg-purple-50 text-purple-600' },
|
||||||
|
{ path: '/portal/leave', label: '休假申请', icon: CalendarClock, color: 'bg-cyan-50 text-cyan-600' },
|
||||||
{ path: '/portal/policies', label: '规章制度', icon: ScrollText, color: 'bg-amber-50 text-amber-600' },
|
{ path: '/portal/policies', label: '规章制度', icon: ScrollText, color: 'bg-amber-50 text-amber-600' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { CalendarClock, Plus, X, Clock, CheckCircle2, XCircle, RotateCcw, Send } from 'lucide-react'
|
||||||
|
import { portalApi } from '../../lib/api-services'
|
||||||
|
import { useConfirm } from '../../hooks/useConfirm'
|
||||||
|
|
||||||
|
const LEAVE_TYPE_MAP: Record<string, string> = {
|
||||||
|
SICK: '病假',
|
||||||
|
PERSONAL: '事假',
|
||||||
|
ANNUAL: '年假',
|
||||||
|
MATERNITY: '产假',
|
||||||
|
OTHER: '其他',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
|
||||||
|
PENDING: { label: '待审批', color: 'bg-amber-50 text-amber-700', icon: <Clock className="w-3 h-3" /> },
|
||||||
|
APPROVED: { label: '已批准', color: 'bg-green-50 text-green-700', icon: <CheckCircle2 className="w-3 h-3" /> },
|
||||||
|
REJECTED: { label: '已驳回', color: 'bg-red-50 text-red-700', icon: <XCircle className="w-3 h-3" /> },
|
||||||
|
CANCELLED: { label: '已撤回', color: 'bg-gray-100 text-gray-500', icon: <RotateCcw className="w-3 h-3" /> },
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmtDate = (d: string) => new Date(d).toLocaleDateString('zh-CN')
|
||||||
|
|
||||||
|
export default function MyLeave() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [formLeaveType, setFormLeaveType] = useState('PERSONAL')
|
||||||
|
const [formStartDate, setFormStartDate] = useState('')
|
||||||
|
const [formEndDate, setFormEndDate] = useState('')
|
||||||
|
const [formDays, setFormDays] = useState(1)
|
||||||
|
const [formReason, setFormReason] = useState('')
|
||||||
|
|
||||||
|
const { data: list = [], isLoading } = useQuery<any[]>({
|
||||||
|
queryKey: ['portal-leaves'],
|
||||||
|
queryFn: () => portalApi.myLeaves(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => portalApi.submitLeave(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['portal-leaves'] })
|
||||||
|
toast.success('休假申请已提交,等待审批')
|
||||||
|
setShowForm(false)
|
||||||
|
setFormLeaveType('PERSONAL'); setFormStartDate(''); setFormEndDate(''); setFormDays(1); setFormReason('')
|
||||||
|
},
|
||||||
|
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '提交失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const cancelMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => portalApi.cancelLeave(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['portal-leaves'] })
|
||||||
|
toast.success('已撤回')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const pendingCount = list.filter((r: any) => r.status === 'PENDING').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CalendarClock className="w-5 h-5 text-primary" />
|
||||||
|
<h1 className="text-base font-bold">我的休假</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 发起申请按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(true)}
|
||||||
|
className="w-full bg-primary text-white rounded-lg py-3 flex items-center justify-center gap-2 text-sm font-medium active:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
发起休假申请
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 待审批提醒 */}
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<div className="bg-amber-50 rounded-lg px-3 py-2 flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4 text-amber-600" />
|
||||||
|
<span className="text-xs text-amber-700">有 {pendingCount} 条申请待审批</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 申请列表 */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-12 text-gray-400 text-sm">加载中...</div>
|
||||||
|
) : list.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-lg p-8 text-center">
|
||||||
|
<p className="text-sm text-gray-400">暂无休假申请</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{list.map((item: any) => {
|
||||||
|
const st = STATUS_MAP[item.status] || STATUS_MAP.PENDING
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="bg-white rounded-lg p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{LEAVE_TYPE_MAP[item.leaveType] || item.leaveType}</span>
|
||||||
|
<span className="text-xs text-gray-500">{item.days} 天</span>
|
||||||
|
</div>
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs ${st.color}`}>
|
||||||
|
{st.icon}{st.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600">
|
||||||
|
{fmtDate(item.startDate)} ~ {fmtDate(item.endDate)}
|
||||||
|
</div>
|
||||||
|
{item.reason && (
|
||||||
|
<div className="text-xs text-gray-500">事由:{item.reason}</div>
|
||||||
|
)}
|
||||||
|
{item.approveRemark && (
|
||||||
|
<div className="text-xs text-gray-500 border-t pt-1 mt-1">
|
||||||
|
审批意见:{item.approveRemark}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.status === 'PENDING' && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (await confirm({ title: '撤回申请', message: '确认撤回该申请?' })) cancelMutation.mutate(item.id)
|
||||||
|
}}
|
||||||
|
className="text-xs text-gray-400 hover:text-amber-600 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3 h-3" />撤回
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 申请表单弹窗 */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setShowForm(false)}>
|
||||||
|
<div className="bg-white rounded-xl max-w-md w-full p-5 space-y-4" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-bold flex items-center gap-2">
|
||||||
|
<Send className="w-4 h-4 text-primary" />休假申请
|
||||||
|
</h3>
|
||||||
|
<button onClick={() => setShowForm(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500 mb-1 block">休假类型</label>
|
||||||
|
<select
|
||||||
|
value={formLeaveType}
|
||||||
|
onChange={(e) => setFormLeaveType(e.target.value)}
|
||||||
|
className="w-full h-10 rounded-lg border border-gray-200 bg-white px-3 text-sm"
|
||||||
|
>
|
||||||
|
{Object.entries(LEAVE_TYPE_MAP).map(([k, v]) => (
|
||||||
|
<option key={k} value={k}>{v}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500 mb-1 block">开始日期</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={formStartDate}
|
||||||
|
onChange={(e) => setFormStartDate(e.target.value)}
|
||||||
|
className="w-full h-10 rounded-lg border border-gray-200 px-3 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500 mb-1 block">结束日期</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={formEndDate}
|
||||||
|
onChange={(e) => setFormEndDate(e.target.value)}
|
||||||
|
className="w-full h-10 rounded-lg border border-gray-200 px-3 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500 mb-1 block">请假天数</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0.5}
|
||||||
|
step={0.5}
|
||||||
|
value={formDays}
|
||||||
|
onChange={(e) => setFormDays(Number(e.target.value))}
|
||||||
|
className="w-full h-10 rounded-lg border border-gray-200 px-3 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500 mb-1 block">事由</label>
|
||||||
|
<textarea
|
||||||
|
value={formReason}
|
||||||
|
onChange={(e) => setFormReason(e.target.value)}
|
||||||
|
placeholder="请简要说明请假原因"
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!formStartDate || !formEndDate) { toast.error('请选择日期'); return }
|
||||||
|
submitMutation.mutate({
|
||||||
|
leaveType: formLeaveType,
|
||||||
|
startDate: formStartDate,
|
||||||
|
endDate: formEndDate,
|
||||||
|
days: formDays,
|
||||||
|
reason: formReason,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
disabled={submitMutation.isPending}
|
||||||
|
className="w-full bg-primary text-white rounded-lg py-2.5 text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitMutation.isPending ? '提交中...' : '提交申请'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* 展示入职步骤进度、材料提交状态、待完成项
|
* 展示入职步骤进度、材料提交状态、待完成项
|
||||||
*/
|
*/
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Check, Clock, AlertCircle, FileText, Upload, User, Phone, Banknote } from 'lucide-react'
|
import { Check, Clock, AlertCircle, FileText, Upload, User, Banknote } from 'lucide-react'
|
||||||
import { portalApi } from '../../lib/api-services'
|
import { portalApi } from '../../lib/api-services'
|
||||||
import Card from '../../components/ui/Card'
|
import Card from '../../components/ui/Card'
|
||||||
import { InlineAlert } from '../../components/ui/InlineAlert'
|
import { InlineAlert } from '../../components/ui/InlineAlert'
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { UserX, Clock, Check, X, FileText } from 'lucide-react'
|
import { UserX, Clock, FileText } from 'lucide-react'
|
||||||
import { portalApi } from '../../lib/api-services'
|
import { portalApi } from '../../lib/api-services'
|
||||||
import Card from '../../components/ui/Card'
|
import Card from '../../components/ui/Card'
|
||||||
import Button from '../../components/ui/Button'
|
import Button from '../../components/ui/Button'
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { useState, useRef } from "react"
|
import { useState, useRef } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { rosterApi, attachmentApi } from '../../lib/api-services'
|
import { attachmentApi } from '../../lib/api-services'
|
||||||
import Card from "../../components/ui/Card"
|
import Card from "../../components/ui/Card"
|
||||||
import Button from "../../components/ui/Button"
|
import Button from "../../components/ui/Button"
|
||||||
import { Input, Label, Select } from "../../components/ui/Input"
|
import { Select } from "../../components/ui/Input"
|
||||||
import Modal from "../../components/ui/Modal"
|
import { Paperclip, Trash2, Eye, Download } from "lucide-react"
|
||||||
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
|
|
||||||
import { fmt } from "./shared"
|
|
||||||
|
|
||||||
export default function AttachmentInfo({ employeeId, attachments }: { employeeId: string; attachments: any[] }) {
|
export default function AttachmentInfo({ employeeId, attachments }: { employeeId: string; attachments: any[] }) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { useState, useRef } from "react"
|
import { useState } from "react"
|
||||||
import { toast } from "sonner"
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
import { rosterApi } from '../../lib/api-services'
|
||||||
import { rosterApi, employeeApi } from '../../lib/api-services'
|
|
||||||
import Card from "../../components/ui/Card"
|
import Card from "../../components/ui/Card"
|
||||||
import Button from "../../components/ui/Button"
|
import Button from "../../components/ui/Button"
|
||||||
import { Input, Label, Select } from "../../components/ui/Input"
|
import { Input, Label, Select } from "../../components/ui/Input"
|
||||||
import Modal from "../../components/ui/Modal"
|
|
||||||
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
|
|
||||||
import { fmt } from "./shared"
|
import { fmt } from "./shared"
|
||||||
|
|
||||||
/** 考勤/加班/培训合并组件 */
|
/** 考勤/加班/培训合并组件 */
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { QRCodeSVG } from "qrcode.react"
|
import { QRCodeSVG } from "qrcode.react"
|
||||||
import { useUnsavedChanges } from "../../hooks/useUnsavedChanges"
|
|
||||||
import { useState, useRef } from "react"
|
import { useState, useRef } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { rosterApi, attachmentApi, employeeApi } from '../../lib/api-services'
|
import { attachmentApi, employeeApi } from '../../lib/api-services'
|
||||||
import Card from "../../components/ui/Card"
|
import Card from "../../components/ui/Card"
|
||||||
import Button from "../../components/ui/Button"
|
import Button from "../../components/ui/Button"
|
||||||
import { Input, Label, Select } from "../../components/ui/Input"
|
import { Input, Label, Select } from "../../components/ui/Input"
|
||||||
import Modal from "../../components/ui/Modal"
|
import { AlertTriangle, Paperclip, Trash2, Eye, Download } from "lucide-react"
|
||||||
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
|
|
||||||
import { fmt } from "./shared"
|
import { fmt } from "./shared"
|
||||||
|
|
||||||
export default function BasicInfo({ profile, employeeId, attachments }: { profile: any; employeeId: string; attachments: any[] }) {
|
export default function BasicInfo({ profile, employeeId, attachments }: { profile: any; employeeId: string; attachments: any[] }) {
|
||||||
@@ -284,6 +282,12 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-gray-400 mt-2">社保/公积金基数按上年度月均工资核定,每年7月调整。专项附加扣除由员工在portal端填报,无则为0。</p>
|
<p className="text-xs text-gray-400 mt-2">社保/公积金基数按上年度月均工资核定,每年7月调整。专项附加扣除由员工在portal端填报,无则为0。</p>
|
||||||
|
{!editing && (!profile.socialInsBase || !profile.housingFundBase) && (
|
||||||
|
<div className="mt-2 flex items-center gap-2 px-3 py-2 rounded-md bg-amber-50 text-warning text-xs">
|
||||||
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||||
|
<span>该员工{!profile.socialInsBase ? '社保' : '公积金'}基数未设置,发薪时社保/公积金将按0计算。请点击右上角「编辑」填写缴费基数。</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 特殊状态 */}
|
{/* 特殊状态 */}
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import { useState, useRef } from "react"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|
||||||
import Card from "../../components/ui/Card"
|
import Card from "../../components/ui/Card"
|
||||||
import Button from "../../components/ui/Button"
|
import { UserX, DollarSign, Building2 } from "lucide-react"
|
||||||
import { Input, Label, Select } from "../../components/ui/Input"
|
|
||||||
import Modal from "../../components/ui/Modal"
|
|
||||||
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
|
|
||||||
import { fmt, terminateReasonMap } from "./shared"
|
import { fmt, terminateReasonMap } from "./shared"
|
||||||
import { CityHistoryTab } from "./PayslipSocialInfo"
|
import { CityHistoryTab } from "./PayslipSocialInfo"
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { useState, useRef } from "react"
|
import { useState, useRef } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { employeeApi } from '../../lib/api-services'
|
import { employeeApi } from '../../lib/api-services'
|
||||||
|
import { useConfirm } from '../../hooks/useConfirm'
|
||||||
import Card from "../../components/ui/Card"
|
import Card from "../../components/ui/Card"
|
||||||
import Button from "../../components/ui/Button"
|
import Button from "../../components/ui/Button"
|
||||||
import { Input, Label, Select } from "../../components/ui/Input"
|
import { Input, Label, Select } from "../../components/ui/Input"
|
||||||
import Modal from "../../components/ui/Modal"
|
import { FileText, AlertTriangle, X, Paperclip, Trash2, Info, Download } from "lucide-react"
|
||||||
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
|
|
||||||
import { fmt } from "./shared"
|
|
||||||
|
|
||||||
export default function ContractInfo({ employeeId, contracts, hireDate }: { employeeId: string; contracts: any[]; hireDate: string }) {
|
export default function ContractInfo({ employeeId, contracts, hireDate }: { employeeId: string; contracts: any[]; hireDate: string }) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const confirm = useConfirm()
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
const [form, setForm] = useState({ contractType: 'FIXED', signDate: '', startDate: '', endDate: '', contractYears: 3, probationMonths: 0, probationSalary: 0, signMethod: 'PAPER' as 'PAPER' | 'ELECTRONIC', attachmentUrl: '', attachments: [] as { name: string; url: string }[], electronicContractNo: '', electronicContractUrl: '' })
|
const [form, setForm] = useState({ contractType: 'FIXED', signDate: '', startDate: '', endDate: '', contractYears: 3, probationMonths: 0, probationSalary: 0, signMethod: 'PAPER' as 'PAPER' | 'ELECTRONIC', attachmentUrl: '', attachments: [] as { name: string; url: string }[], electronicContractNo: '', electronicContractUrl: '' })
|
||||||
const contractFileRef = useRef<HTMLInputElement>(null)
|
const contractFileRef = useRef<HTMLInputElement>(null)
|
||||||
@@ -281,7 +281,7 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (confirm('确定删除此合同记录?')) deleteContractMutation.mutate(c.id) }}
|
onClick={async () => { if (await confirm({ title: '删除合同', message: '确定删除此合同记录?' })) deleteContractMutation.mutate(c.id) }}
|
||||||
className="text-gray-400 hover:text-danger shrink-0 ml-2 mt-1"
|
className="text-gray-400 hover:text-danger shrink-0 ml-2 mt-1"
|
||||||
title="删除合同"
|
title="删除合同"
|
||||||
>
|
>
|
||||||
@@ -308,7 +308,6 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
|
|||||||
const mime = previewUrl.startsWith('data:') ? previewUrl.match(/data:(.*?);/)?.[1] || '' : ''
|
const mime = previewUrl.startsWith('data:') ? previewUrl.match(/data:(.*?);/)?.[1] || '' : ''
|
||||||
const isImage = mime.startsWith('image/')
|
const isImage = mime.startsWith('image/')
|
||||||
const isPdf = mime === 'application/pdf'
|
const isPdf = mime === 'application/pdf'
|
||||||
const previewable = isImage || isPdf
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => { if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => { if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }}>
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { useState, useRef } from "react"
|
import { useState } from "react"
|
||||||
import { toast } from "sonner"
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|
||||||
import { rosterApi } from '../../lib/api-services'
|
import { rosterApi } from '../../lib/api-services'
|
||||||
import Card from "../../components/ui/Card"
|
import Card from "../../components/ui/Card"
|
||||||
import Button from "../../components/ui/Button"
|
import Button from "../../components/ui/Button"
|
||||||
import { Input, Label, Select } from "../../components/ui/Input"
|
import { Input, Label, Select } from "../../components/ui/Input"
|
||||||
import Modal from "../../components/ui/Modal"
|
import { AlertTriangle, Check } from "lucide-react"
|
||||||
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
|
|
||||||
import { fmt } from "./shared"
|
|
||||||
|
|
||||||
// ========== 违纪记录管理 ==========
|
// ========== 违纪记录管理 ==========
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
* 可被 Roster、Termination、SocialInsurance 等页面复用
|
* 可被 Roster、Termination、SocialInsurance 等页面复用
|
||||||
*/
|
*/
|
||||||
import { useState, ReactNode } from 'react'
|
import { useState, ReactNode } from 'react'
|
||||||
import { X, Phone, Mail, MapPin, Calendar, Briefcase, DollarSign, FileText, AlertTriangle } from 'lucide-react'
|
import { X, Phone, Mail, MapPin, Calendar, Briefcase, DollarSign, AlertTriangle } from 'lucide-react'
|
||||||
import { fmt, DetailTab, TAB_GROUPS, TAB_COUNT_KEYS } from './shared'
|
import { fmt, DetailTab, TAB_GROUPS } from './shared'
|
||||||
|
|
||||||
interface EmployeeSummary {
|
interface EmployeeSummary {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import { useState, useRef } from "react"
|
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { rosterApi } from '../../lib/api-services'
|
import { rosterApi } from '../../lib/api-services'
|
||||||
import { useAuthStore } from "../../store/authStore"
|
import { useAuthStore } from "../../store/authStore"
|
||||||
import Card from "../../components/ui/Card"
|
import Card from "../../components/ui/Card"
|
||||||
import Button from "../../components/ui/Button"
|
import Button from "../../components/ui/Button"
|
||||||
import { Input, Label, Select } from "../../components/ui/Input"
|
import { AlertTriangle, Scale } from "lucide-react"
|
||||||
import Modal from "../../components/ui/Modal"
|
|
||||||
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
|
|
||||||
import { fmt } from "./shared"
|
|
||||||
|
|
||||||
// ========== 仲裁证据链 ==========
|
// ========== 仲裁证据链 ==========
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,17 @@
|
|||||||
import { useState, useRef } from "react"
|
import { useState } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { socialInsuranceApi } from '../../lib/api-services'
|
import { socialInsuranceApi } from '../../lib/api-services'
|
||||||
import Card from "../../components/ui/Card"
|
import Card from "../../components/ui/Card"
|
||||||
import Button from "../../components/ui/Button"
|
import Button from "../../components/ui/Button"
|
||||||
import { Input, Label, Select } from "../../components/ui/Input"
|
import { Input, Label, Select } from "../../components/ui/Input"
|
||||||
import Modal from "../../components/ui/Modal"
|
import Modal from "../../components/ui/Modal"
|
||||||
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
|
|
||||||
import { fmt } from "./shared"
|
import { fmt } from "./shared"
|
||||||
|
|
||||||
/** 薪酬社保合并组件(工资条 / 缴纳记录) */
|
/** 薪酬社保合并组件(工资条 / 缴纳记录) */
|
||||||
export default function PayslipSocialInfo({ payslips, monthlyProcessRecords }: { payslips: any[]; socialInsRecords: any[]; housingFundRecords: any[]; monthlyProcessRecords: any[] }) {
|
export default function PayslipSocialInfo({ payslips, monthlyProcessRecords }: { payslips: any[]; socialInsRecords: any[]; housingFundRecords: any[]; monthlyProcessRecords: any[] }) {
|
||||||
const [subTab, setSubTab] = useState<'payslip' | 'monthly'>('payslip')
|
const [subTab, setSubTab] = useState<'payslip' | 'monthly'>('payslip')
|
||||||
|
|
||||||
const changeTypeMap: Record<string, string> = { ONBOARDING: '入职', REHIRE: '重新入职', ADJUST: '调基', TERMINATION: '离职/解聘', CITY_CHANGE: '城市变更' }
|
|
||||||
const changeTypeColor: Record<string, string> = { ONBOARDING: 'bg-green-50 text-safe', REHIRE: 'bg-blue-50 text-blue-600', ADJUST: 'bg-amber-50 text-amber-600', TERMINATION: 'bg-red-50 text-danger', CITY_CHANGE: 'bg-cyan-50 text-cyan-600' }
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { useState, useRef } from "react"
|
import { useState } from "react"
|
||||||
import { toast } from "sonner"
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|
||||||
import { rosterApi } from '../../lib/api-services'
|
import { rosterApi } from '../../lib/api-services'
|
||||||
import Card from "../../components/ui/Card"
|
import Card from "../../components/ui/Card"
|
||||||
import Button from "../../components/ui/Button"
|
import Button from "../../components/ui/Button"
|
||||||
import { Input, Label, Select } from "../../components/ui/Input"
|
import { Input, Label, Select } from "../../components/ui/Input"
|
||||||
import Modal from "../../components/ui/Modal"
|
import { AlertTriangle, Check } from "lucide-react"
|
||||||
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
|
|
||||||
import { fmt } from "./shared"
|
|
||||||
|
|
||||||
// ========== 绩效记录管理 ==========
|
// ========== 绩效记录管理 ==========
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user