优化: 大文件拆分+代码分割+按需加载+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",
|
||||
"node-cron": "^3.0.3",
|
||||
"openai": "^6.48.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"redis": "^6.1.0",
|
||||
"uuid": "^10.0.0",
|
||||
"xlsx": "^0.18.5",
|
||||
@@ -569,6 +571,12 @@
|
||||
"@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": {
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmmirror.com/@prisma/client/-/client-5.22.0.tgz",
|
||||
@@ -1128,6 +1136,15 @@
|
||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -1456,6 +1473,12 @@
|
||||
"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": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/compress-commons/-/compress-commons-4.1.2.tgz",
|
||||
@@ -1613,6 +1636,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.11.21",
|
||||
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz",
|
||||
@@ -1945,6 +1977,12 @@
|
||||
"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": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmmirror.com/fast-csv/-/fast-csv-4.3.6.tgz",
|
||||
@@ -1958,6 +1996,12 @@
|
||||
"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": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -2201,6 +2245,12 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -2353,6 +2403,15 @@
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"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": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
@@ -2869,6 +2928,15 @@
|
||||
"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": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz",
|
||||
@@ -2985,6 +3053,79 @@
|
||||
"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": {
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmmirror.com/prisma/-/prisma-5.22.0.tgz",
|
||||
@@ -3011,6 +3152,22 @@
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"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": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -3024,6 +3181,16 @@
|
||||
"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": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.3.tgz",
|
||||
@@ -3040,6 +3207,12 @@
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz",
|
||||
@@ -3121,6 +3294,15 @@
|
||||
"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": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/redis/-/redis-6.1.0.tgz",
|
||||
@@ -3192,6 +3374,15 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
@@ -3210,6 +3401,22 @@
|
||||
"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": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz",
|
||||
@@ -3351,6 +3558,15 @@
|
||||
"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": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz",
|
||||
@@ -3372,6 +3588,15 @@
|
||||
"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": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
@@ -3465,6 +3690,24 @@
|
||||
"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": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.2.7.tgz",
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"multer": "^2.2.0",
|
||||
"node-cron": "^3.0.3",
|
||||
"openai": "^6.48.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"redis": "^6.1.0",
|
||||
"uuid": "^10.0.0",
|
||||
"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[]
|
||||
shiftAssignments ShiftAssignment[]
|
||||
leaveRecords LeaveRecord[]
|
||||
leaveRequests LeaveRequest[]
|
||||
calendarEvents CalendarEvent[]
|
||||
consultations Consultation[]
|
||||
workProcesses WorkProcess[]
|
||||
@@ -263,6 +264,7 @@ model Employee {
|
||||
specialDeductionRecords SpecialDeductionRecord[]
|
||||
shiftAssignments ShiftAssignment[]
|
||||
leaveRecords LeaveRecord[]
|
||||
leaveRequests LeaveRequest[]
|
||||
calendarEvents CalendarEvent[]
|
||||
workProcesses WorkProcess[]
|
||||
specialStatuses EmployeeSpecialStatus[]
|
||||
@@ -1162,6 +1164,33 @@ model LeaveRecord {
|
||||
@@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 {
|
||||
@@ -1311,3 +1340,27 @@ model EmployeeSpecialStatus {
|
||||
@@index([orgId, type])
|
||||
@@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 { errorHandler } from './middleware/errorHandler'
|
||||
import { apiLimiter } from './middleware/rateLimit'
|
||||
import logger from './lib/logger'
|
||||
|
||||
const app = express()
|
||||
|
||||
@@ -62,6 +63,8 @@ import workProcessRoutes from './routes/work-process.routes'
|
||||
import enterpriseTemplateRoutes from './routes/enterprise-template.routes'
|
||||
import specialStatusRoutes from './routes/special-status.routes'
|
||||
import companyFileRoutes from './routes/company-file.routes'
|
||||
import acceptanceTestRoutes from './routes/acceptance-test.routes'
|
||||
import leaveRoutes from './routes/leave.routes'
|
||||
app.use('/api/v1/auth', authRoutes)
|
||||
app.use('/api/v1/dashboard', dashboardRoutes)
|
||||
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/special-statuses', specialStatusRoutes)
|
||||
app.use('/api/v1/company-files', companyFileRoutes)
|
||||
app.use('/api/v1/acceptance-tests', acceptanceTestRoutes)
|
||||
app.use('/api/v1/leaves', leaveRoutes)
|
||||
|
||||
app.use(errorHandler)
|
||||
|
||||
// RAG 知识库自动初始化(异步,不阻塞启动)
|
||||
import { seedKnowledgeBase } from './services/rag.service'
|
||||
seedKnowledgeBase().catch((err) => {
|
||||
console.warn('[RAG] 知识库初始化失败,AI 问答将不使用 RAG 检索:', err?.message || err)
|
||||
logger.warn({ err }, 'RAG 知识库初始化失败,AI 问答将不使用 RAG 检索')
|
||||
})
|
||||
|
||||
export default app
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import app from './app'
|
||||
import { validateEnv } from './lib/config'
|
||||
import logger from './lib/logger'
|
||||
|
||||
validateEnv()
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3000
|
||||
|
||||
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 {
|
||||
code: string
|
||||
expiresAt: number
|
||||
@@ -28,11 +30,11 @@ async function getRedis() {
|
||||
try {
|
||||
const { createClient } = await import('redis')
|
||||
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()
|
||||
console.log('[Redis] 验证码存储已连接')
|
||||
logger.info('Redis 验证码存储已连接')
|
||||
} catch (err) {
|
||||
console.warn('[Redis] 连接失败,降级为内存存储:', err)
|
||||
logger.warn({ err }, 'Redis 连接失败,降级为内存存储')
|
||||
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 })
|
||||
return
|
||||
} catch (err) {
|
||||
console.warn('[Redis] set 失败,降级内存:', err)
|
||||
logger.warn({ err }, 'Redis set 失败,降级内存')
|
||||
}
|
||||
}
|
||||
memoryStore.set(key, entry)
|
||||
@@ -81,7 +83,7 @@ export async function getCode(key: string): Promise<CodeEntry | null> {
|
||||
}
|
||||
return entry
|
||||
} 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
|
||||
} 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}`)
|
||||
return
|
||||
} catch (err) {
|
||||
console.warn('[Redis] del 失败,降级内存:', err)
|
||||
logger.warn({ err }, 'Redis del 失败,降级内存')
|
||||
}
|
||||
}
|
||||
memoryStore.delete(key)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import logger from './logger'
|
||||
|
||||
const required = ['JWT_SECRET', 'JWT_REFRESH_SECRET', 'ENCRYPTION_KEY']
|
||||
const defaults: Record<string, string> = {
|
||||
JWT_SECRET: 'dev-secret',
|
||||
@@ -13,10 +15,10 @@ export function validateEnv(): void {
|
||||
}
|
||||
}
|
||||
if (missing.length > 0 && process.env.NODE_ENV === 'production') {
|
||||
console.error(`[FATAL] 以下环境变量未设置或使用了默认值,生产环境禁止启动: ${missing.join(', ')}`)
|
||||
logger.fatal({ missing }, '以下环境变量未设置或使用了默认值,生产环境禁止启动')
|
||||
process.exit(1)
|
||||
}
|
||||
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 prisma from '../lib/prisma'
|
||||
import logger from '../lib/logger'
|
||||
|
||||
export async function auditLog(
|
||||
req: AuthRequest,
|
||||
@@ -22,6 +23,6 @@ export async function auditLog(
|
||||
},
|
||||
})
|
||||
} 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 { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'
|
||||
import { AppError } from '../lib/AppError'
|
||||
import logger from '../lib/logger'
|
||||
|
||||
export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) {
|
||||
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({
|
||||
success: false,
|
||||
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 { z } from 'zod'
|
||||
import {
|
||||
createAttendanceConfirmation,
|
||||
batchCreateAttendanceConfirmations,
|
||||
getAttendanceConfirmations,
|
||||
confirmAttendance,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import prisma from '../lib/prisma'
|
||||
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'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import prisma from '../lib/prisma'
|
||||
import { renderTemplate } from '../services/template.service'
|
||||
|
||||
const router = Router()
|
||||
|
||||
|
||||
@@ -255,7 +255,6 @@ router.get('/roster', authMiddleware, async (req: AuthRequest, res: Response, ne
|
||||
const search = req.query.search as string | undefined
|
||||
const status = req.query.status as string | undefined
|
||||
const department = req.query.department as string | undefined
|
||||
const contractStatus = req.query.contractStatus as string | undefined
|
||||
|
||||
const where: any = { orgId }
|
||||
if (department) where.department = department
|
||||
|
||||
@@ -324,7 +324,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
|
||||
})
|
||||
|
||||
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) {
|
||||
const msg = e?.message || ''
|
||||
if (msg.includes('Unique constraint')) {
|
||||
@@ -720,7 +720,6 @@ router.post('/payroll', authMiddleware, upload.single('file'), async (req: AuthR
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ success: false, message: '请上传文件' })
|
||||
const orgId = req.user!.orgId
|
||||
const userId = req.user!.id
|
||||
const batchId = req.body.batchId as string
|
||||
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) => {
|
||||
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 } })
|
||||
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) {
|
||||
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 {
|
||||
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) {
|
||||
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) {
|
||||
next(err)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
import { Router } from 'express'
|
||||
import prisma from '../lib/prisma'
|
||||
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()
|
||||
|
||||
@@ -88,8 +89,7 @@ router.get('/dashboard', async (_req: AuthRequest, res, next) => {
|
||||
*/
|
||||
router.get('/orgs', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1
|
||||
const pageSize = parseInt(req.query.pageSize as string) || 20
|
||||
const { page, pageSize } = parsePagination(req.query)
|
||||
const search = (req.query.search as string) || ''
|
||||
const planFilter = (req.query.plan as string) || ''
|
||||
|
||||
@@ -154,19 +154,7 @@ router.post('/orgs', async (req: AuthRequest, res, next) => {
|
||||
const {
|
||||
name, plan, maxEmployees, city, contactName, contactPhone,
|
||||
adminName, adminPhone, adminPassword,
|
||||
} = req.body as {
|
||||
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位' } })
|
||||
}
|
||||
} = createOrgSchema.parse(req.body)
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { phone: adminPhone } })
|
||||
if (existing) {
|
||||
@@ -244,10 +232,7 @@ router.get('/orgs/:id', async (req: AuthRequest, res, next) => {
|
||||
*/
|
||||
router.put('/orgs/:id', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { name, plan, maxEmployees, city, contactName, contactPhone } = req.body as {
|
||||
name?: string; plan?: string; maxEmployees?: number;
|
||||
city?: string; contactName?: string; contactPhone?: string
|
||||
}
|
||||
const { name, plan, maxEmployees, city, contactName, contactPhone } = updateOrgSchema.parse(req.body)
|
||||
|
||||
const updateData: any = {}
|
||||
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) => {
|
||||
try {
|
||||
const { adminName, adminPhone, adminPassword } = req.body as {
|
||||
adminName?: string; adminPhone?: string; adminPassword?: string
|
||||
}
|
||||
const { adminName, adminPhone, adminPassword } = updateOrgAdminSchema.parse(req.body)
|
||||
|
||||
// 找到该企业的 ADMIN 角色用户(第一个管理员)
|
||||
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) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1
|
||||
const pageSize = parseInt(req.query.pageSize as string) || 20
|
||||
const { page, pageSize } = parsePagination(req.query)
|
||||
const search = (req.query.search 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) => {
|
||||
try {
|
||||
const { name, phone, password } = req.body as { name: string; phone: string; password: string }
|
||||
|
||||
if (!name || !phone || !password) {
|
||||
return res.status(400).json({ success: false, error: { code: 'VALIDATION', message: '姓名、手机号、密码不能为空' } })
|
||||
}
|
||||
const { name, phone, password } = createPlatformAdminSchema.parse(req.body)
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { phone } })
|
||||
if (existing) {
|
||||
|
||||
@@ -838,4 +838,64 @@ router.post('/resignation/:id/withdraw', portalAuth, async (req: any, res, next)
|
||||
} 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
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import prisma from '../lib/prisma'
|
||||
import { parsePagination } from '../lib/pagination'
|
||||
import {
|
||||
createSpecialStatus,
|
||||
updateSpecialStatus,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
STATUS_TYPES,
|
||||
getAlertLevel,
|
||||
} from '../services/special-status.service'
|
||||
import { createSpecialStatusSchema, updateSpecialStatusSchema } from '../schemas/special-status.schema'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -22,8 +24,7 @@ const router = Router()
|
||||
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const page = parseInt(req.query.page as string) || 1
|
||||
const pageSize = parseInt(req.query.pageSize as string) || 20
|
||||
const { page, pageSize } = parsePagination(req.query)
|
||||
const type = (req.query.type as string) || ''
|
||||
const status = (req.query.status 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) => {
|
||||
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 })
|
||||
} catch (err: any) {
|
||||
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) => {
|
||||
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 })
|
||||
} catch (err: any) {
|
||||
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()
|
||||
soon7.setDate(soon7.getDate() + 7)
|
||||
const soon30 = new Date()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
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 prisma from '../lib/prisma'
|
||||
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) => {
|
||||
try {
|
||||
const { employeeId, terminationDate, resignationReason, remark } = req.body
|
||||
if (!employeeId || !terminationDate) {
|
||||
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '缺少必填字段' } })
|
||||
}
|
||||
const { employeeId, terminationDate, resignationReason, remark } = resignationSchema.parse(req.body)
|
||||
const result = await createResignation(req.user!.orgId, req.user!.id, { employeeId, terminationDate, resignationReason, remark })
|
||||
await auditLog(req, 'RESIGN', 'EMPLOYEE', employeeId, { resignationReason })
|
||||
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) => {
|
||||
try {
|
||||
const { items } = req.body as {
|
||||
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 { items } = batchTerminatePreviewSchema.parse(req.body)
|
||||
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 } })
|
||||
} 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) => {
|
||||
try {
|
||||
const { items } = req.body as {
|
||||
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 { items } = batchTerminateSchema.parse(req.body)
|
||||
const result = await batchTerminate(req.user!.orgId, req.user!.id, items)
|
||||
for (const id of result.success) {
|
||||
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) => {
|
||||
try {
|
||||
const result = await createDraft(req.user!.orgId, req.user!.id, req.body)
|
||||
const emp = await prisma.employee.findFirst({ where: { id: req.body.employeeId }, select: { name: true, department: true } })
|
||||
const data = createTerminationDraftSchema.parse(req.body)
|
||||
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, {
|
||||
employeeName: emp?.name || '',
|
||||
department: emp?.department || '',
|
||||
reason: req.body.reason || '',
|
||||
type: req.body.type || 'TERMINATION',
|
||||
terminationDate: req.body.terminationDate || '',
|
||||
compensation: req.body.compensation || 0,
|
||||
reason: data.reason || '',
|
||||
type: data.type || 'TERMINATION',
|
||||
terminationDate: data.terminationDate || '',
|
||||
compensation: data.compensation || 0,
|
||||
})
|
||||
res.json({ success: true, data: result })
|
||||
} 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) => {
|
||||
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 })
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'CONFLICT' || err?.code === 'NOT_FOUND') {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import prisma from '../lib/prisma'
|
||||
import { parsePagination } from '../lib/pagination'
|
||||
import { executeWorkProcess, generateDocument, PROCESS_TYPES, PROCESS_STATUS } from '../services/work-process.service'
|
||||
import { createWorkProcessSchema, updateWorkProcessSchema } from '../schemas/work-process.schema'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 创建办理(含草稿)
|
||||
router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { type, title, employeeId, formData, status = 'DRAFT', remark } = req.body
|
||||
if (!type || !PROCESS_TYPES[type]) {
|
||||
return res.status(400).json({ success: false, error: { code: 'INVALID_TYPE', message: '无效的流程类型' } })
|
||||
}
|
||||
const data = createWorkProcessSchema.parse(req.body)
|
||||
const { type, title, employeeId, formData, status, remark } = data
|
||||
const process = await (prisma as any).workProcess.create({
|
||||
data: {
|
||||
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) => {
|
||||
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 }
|
||||
if (type) where.type = type
|
||||
if (status) where.status = status
|
||||
@@ -42,10 +43,10 @@ router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: Ne
|
||||
where,
|
||||
include: { employee: { select: { id: true, name: true, department: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (Number(page) - 1) * Number(pageSize),
|
||||
take: Number(pageSize),
|
||||
skip: (page - 1) * 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) {
|
||||
next(err)
|
||||
}
|
||||
@@ -79,7 +80,7 @@ router.patch('/:id', authMiddleware, async (req: AuthRequest, res: Response, nex
|
||||
if (existing.status !== 'DRAFT') {
|
||||
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({
|
||||
where: { id: req.params.id },
|
||||
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 doc = generateDocument(process.type, process.formData, org?.name || '')
|
||||
const doc = await generateDocument(process.type, process.formData, org?.name || '')
|
||||
const documents = doc.content ? [doc] : []
|
||||
const updated = await (prisma as any).workProcess.update({
|
||||
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 || '未知错误'}` } })
|
||||
}
|
||||
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 updated = await (prisma as any).workProcess.update({
|
||||
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: '办理记录不存在' } })
|
||||
}
|
||||
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 })
|
||||
} catch (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),
|
||||
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)
|
||||
}
|
||||
|
||||
// 保存系统计算值(覆盖前)
|
||||
const systemSocialEmp = socialEmp
|
||||
const systemSocialOrg = socialOrg
|
||||
const systemHousingEmp = housingEmp
|
||||
const systemHousingOrg = housingOrg
|
||||
|
||||
// 手动覆盖社保值
|
||||
if (options?.overrideSocial) {
|
||||
if (options.overrideSocial.socialEmp !== undefined) socialEmp = options.overrideSocial.socialEmp
|
||||
@@ -229,9 +235,11 @@ export async function calcBatchEntry(
|
||||
|
||||
// 个税计算
|
||||
let tax = 0
|
||||
let taxBreakdown: any = null
|
||||
if (batchType === 'BONUS') {
|
||||
// 年终奖单独计税
|
||||
tax = calcBonusTax(inputs.bonus)
|
||||
taxBreakdown = { method: '单独计税(年终奖)', bonus: inputs.bonus, tax }
|
||||
} else {
|
||||
// 累计预扣法(补偿金也走累计预扣,但无社保公积金扣除)
|
||||
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 ytdSpecialDeduction = employee.specialDeduction * Number(month.slice(5, 7))
|
||||
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)
|
||||
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
|
||||
@@ -263,7 +285,12 @@ export async function calcBatchEntry(
|
||||
socialOrg: Math.round(socialOrg * 100) / 100,
|
||||
housingEmp: Math.round(housingEmp * 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,
|
||||
taxBreakdown,
|
||||
totalPay: Math.round(totalPay * 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 [
|
||||
employeeCount, highRisks, pendingRisks, riskItems, resolvedItems,
|
||||
employeeCount, _highRisks, _pendingRisks, riskItems, resolvedItems,
|
||||
overtimeRecords, payslips, batchEntries, socialConfig, housingConfig,
|
||||
monthContracts, monthTerminations, monthDisciplinary, monthAttendance,
|
||||
monthSeverancePay,
|
||||
@@ -1217,17 +1217,13 @@ export async function getCostAnalysis(orgId: string, month: string) {
|
||||
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 prevMonthEnd = new Date(year, monthNum - 1, 0, 23, 59, 59)
|
||||
const prevMonthStr = `${prevMonthStart.getFullYear()}-${String(prevMonthStart.getMonth() + 1).padStart(2, '0')}`
|
||||
|
||||
// 去年同月(同比)
|
||||
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')}`
|
||||
|
||||
// 获取各月归档批次汇总(按员工去重,与 getDashboardData 口径一致)
|
||||
@@ -1802,7 +1798,7 @@ export async function getAnnualValueReport(orgId: string, year: number) {
|
||||
|
||||
const [
|
||||
risksResolved,
|
||||
lossAvoidedAgg,
|
||||
_lossAvoidedAgg,
|
||||
aiConversations,
|
||||
aiReviews,
|
||||
contractsSigned,
|
||||
@@ -2043,11 +2039,6 @@ export async function getAnnualValueReport(orgId: string, year: number) {
|
||||
// 总价值 = 规避损失 + 节约成本
|
||||
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 = {
|
||||
risksResolved,
|
||||
lossAvoided,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import prisma from '../lib/prisma'
|
||||
import { encrypt } from '../lib/crypto'
|
||||
import crypto from 'crypto'
|
||||
import { createDraft as createTerminationDraft, executeTermination, createResignation } from './termination.service'
|
||||
import { createEmployee, addContract, batchRenew } from './contract.service'
|
||||
import { createDraft as createTerminationDraft, executeTermination } from './termination.service'
|
||||
import { createEmployee, addContract } from './contract.service'
|
||||
import { runRiskDetection } from './risk.service'
|
||||
|
||||
// 13类流程定义
|
||||
@@ -67,7 +66,7 @@ export async function executeWorkProcess(processId: string, type: string, formDa
|
||||
return { employeeId }
|
||||
}
|
||||
case 'CONFIRM': {
|
||||
const { employeeId, confirmDate, regularSalary } = formData
|
||||
const { employeeId, regularSalary } = formData
|
||||
if (employeeId) {
|
||||
if (regularSalary) {
|
||||
await prisma.employee.update({
|
||||
@@ -196,7 +195,7 @@ export async function executeWorkProcess(processId: string, type: string, formDa
|
||||
return {}
|
||||
}
|
||||
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, {
|
||||
name,
|
||||
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> = {
|
||||
INCOME_CERT: (data, org) => `收入证明
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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>
|
||||
<style>
|
||||
:root {
|
||||
@@ -51,6 +54,7 @@ body {
|
||||
}
|
||||
.header .btn:hover { opacity: 0.85; }
|
||||
.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-delete { background: #fee2e2; color: var(--danger); }
|
||||
.header .btn-download { background: #d1fae5; color: var(--success); }
|
||||
@@ -177,6 +181,68 @@ body {
|
||||
/* Collapsed state */
|
||||
.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-wrap { position: relative; display: inline-block; }
|
||||
.autocomplete-list {
|
||||
@@ -198,11 +264,15 @@ body {
|
||||
.autocomplete-empty { padding: 8px 12px; font-size: 0.75rem; color: var(--gray); text-align: center; }
|
||||
|
||||
@media print {
|
||||
.header, .stats-bar { position: static; }
|
||||
.header, .stats-bar, .toolbar { display: none !important; }
|
||||
.domain-section { break-inside: avoid; }
|
||||
.domain-section.collapsed .test-table { display: table !important; }
|
||||
.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>
|
||||
</head>
|
||||
@@ -221,11 +291,11 @@ body {
|
||||
<option value="">-- 加载已保存 --</option>
|
||||
</select>
|
||||
<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="clearAllSaved()" style="background:#fee2e2;color:#ef4444">清空全部</button>
|
||||
<button class="btn btn-download" onclick="downloadCSV()">下载CSV</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>
|
||||
|
||||
@@ -239,6 +309,25 @@ body {
|
||||
<div class="stat-item">完成率 <span class="stat-num" id="statRate">0%</span></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 -->
|
||||
<div class="main" id="mainContent"></div>
|
||||
|
||||
@@ -516,10 +605,68 @@ const DOMAINS = [
|
||||
{ id:"24.2", func:"命令面板", steps:"按Cmd+K/Ctrl+K", 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 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() {
|
||||
@@ -538,11 +685,12 @@ function renderAll() {
|
||||
<th class="col-num">#</th><th class="col-func">功能</th>
|
||||
<th class="col-steps">操作步骤</th><th class="col-expected">预期结果</th>
|
||||
<th class="col-result">结果</th><th class="col-remark">备注</th>
|
||||
<th class="screenshot-cell">截图</th>
|
||||
</tr></thead>
|
||||
<tbody>`;
|
||||
domain.items.forEach((item, ii) => {
|
||||
const uid = `${di}-${ii}`;
|
||||
html += `<tr>
|
||||
html += `<tr id="row-${uid}">
|
||||
<td class="col-num">${item.id}</td>
|
||||
<td class="col-func">${item.func}</td>
|
||||
<td class="col-steps">${item.steps}</td>
|
||||
@@ -556,6 +704,7 @@ function renderAll() {
|
||||
</div>
|
||||
</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>`;
|
||||
});
|
||||
html += `</tbody></table></div>`;
|
||||
@@ -584,24 +733,41 @@ function renderAll() {
|
||||
</tr>`;
|
||||
html += `</tbody></table></div>`;
|
||||
|
||||
// Conclusion
|
||||
// Conclusion (auto-calculated)
|
||||
html += `<div class="conclusion-section">
|
||||
<h2>验收结论</h2>
|
||||
<div class="field"><label>验收人</label><input type="text" id="conclusionName" placeholder="验收人姓名"></div>
|
||||
<div class="field"><label>验收日期</label><input type="text" id="conclusionDate" placeholder="YYYY-MM-DD"></div>
|
||||
<div class="field"><label>总体结论</label>
|
||||
<div class="checkbox-group">
|
||||
<label><input type="radio" name="conclusion" value="pass"> 通过</label>
|
||||
<label><input type="radio" name="conclusion" value="conditional"> 有条件通过</label>
|
||||
<label><input type="radio" name="conclusion" value="fail"> 不通过</label>
|
||||
<h2>验收结论(系统自动计算)</h2>
|
||||
<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" readonly></div>
|
||||
<div class="field"><label>总体结论</label><span id="conclusionResult" style="font-size:0.875rem;font-weight:600;color:var(--gray);">未测试</span></div>
|
||||
<div class="field"><label>通过率</label><span id="conclusionPassRate" style="font-size:0.875rem;">-</span></div>
|
||||
<div class="field"><label>主要问题</label></div>
|
||||
<textarea id="conclusionIssues" placeholder="系统自动汇总失败和部分通过的问题..." readonly style="background:#f9fafb;"></textarea>
|
||||
</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 class="field"><label>遗留问题</label></div>
|
||||
<textarea id="conclusionIssues" placeholder="1. ... 2. ... 3. ..."></textarea>
|
||||
</div>`;
|
||||
|
||||
main.innerHTML = html;
|
||||
updateStats();
|
||||
initSignatures();
|
||||
}
|
||||
|
||||
// ========== 交互 ==========
|
||||
@@ -612,69 +778,226 @@ function toggleDomain(di) {
|
||||
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) {
|
||||
updateStats();
|
||||
autoSave();
|
||||
scheduleAutoSave();
|
||||
}
|
||||
|
||||
function onRemarkChange(di, ii, value) {
|
||||
autoSave();
|
||||
scheduleAutoSave();
|
||||
}
|
||||
|
||||
function onVerifierChange() {
|
||||
updateSavedList();
|
||||
autoSave();
|
||||
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 acItems = [];
|
||||
let savedRecords = [];
|
||||
|
||||
function getSavedNames() {
|
||||
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 {
|
||||
const data = JSON.parse(localStorage.getItem(key) || '{}');
|
||||
const vals = Object.values(data.results || {});
|
||||
const pass = vals.filter(v => v === 'pass').length;
|
||||
const total = vals.length;
|
||||
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));
|
||||
async function loadSavedList() {
|
||||
try {
|
||||
const res = await apiCall('GET', '/acceptance-tests');
|
||||
savedRecords = res.data || [];
|
||||
updateSavedSelect();
|
||||
} catch (err) { savedRecords = []; }
|
||||
}
|
||||
|
||||
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() {
|
||||
const input = document.getElementById('verifierName');
|
||||
const list = document.getElementById('autocompleteList');
|
||||
const q = input.value.trim().toLowerCase();
|
||||
const saved = getSavedNames();
|
||||
|
||||
|
||||
if (!q) {
|
||||
// 输入为空时显示全部已保存
|
||||
acItems = saved;
|
||||
acItems = savedRecords.map(r => ({ name: r.verifierName, meta: `${r.testedCount}/${r.totalCount} 已测 · ${r.passCount} 通过${r.status === 'SUBMITTED' ? ' · 已提交' : ''}` }));
|
||||
} else {
|
||||
// 按字母/拼音模糊匹配
|
||||
acItems = saved.filter(s => s.name.toLowerCase().includes(q));
|
||||
acItems = savedRecords.filter(r => r.verifierName.toLowerCase().includes(q)).map(r => ({ name: r.verifierName, meta: `${r.testedCount}/${r.totalCount} 已测 · ${r.passCount} 通过${r.status === 'SUBMITTED' ? ' · 已提交' : ''}` }));
|
||||
}
|
||||
|
||||
|
||||
acActiveIndex = -1;
|
||||
|
||||
|
||||
if (acItems.length === 0) {
|
||||
list.innerHTML = '<div class="autocomplete-empty">无匹配记录</div>';
|
||||
list.classList.add('show');
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = acItems.map((item, i) =>
|
||||
|
||||
list.innerHTML = acItems.map((item, i) =>
|
||||
`<div class="autocomplete-item" data-index="${i}" onmousedown="selectAutocomplete('${item.name}')">
|
||||
<span class="ac-name">${item.name}</span>
|
||||
<span style="display:flex;align-items:center;gap:8px">
|
||||
@@ -789,35 +1112,117 @@ function updateStats() {
|
||||
document.getElementById("total-partial").textContent = totalPartial;
|
||||
document.getElementById("total-untested").textContent = totalUntested;
|
||||
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 collectData() {
|
||||
const results = {};
|
||||
const remarks = {};
|
||||
function updateConclusion(pass, fail, partial, untested, total) {
|
||||
const resultEl = document.getElementById("conclusionResult");
|
||||
const passRateEl = document.getElementById("conclusionPassRate");
|
||||
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) => {
|
||||
domain.items.forEach((item, ii) => {
|
||||
const uid = `${di}-${ii}`;
|
||||
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}`);
|
||||
remarks[item.id] = remarkEl ? remarkEl.value : "";
|
||||
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}`);
|
||||
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 {
|
||||
verifier: document.getElementById("verifierName").value,
|
||||
results, remarks,
|
||||
conclusionName: document.getElementById("conclusionName")?.value || "",
|
||||
conclusionDate: document.getElementById("conclusionDate")?.value || "",
|
||||
conclusionResult: document.querySelector('input[name="conclusion"]:checked')?.value || "",
|
||||
conclusionIssues: document.getElementById("conclusionIssues")?.value || "",
|
||||
savedAt: new Date().toISOString(),
|
||||
conclusionResult,
|
||||
conclusionIssues: issuesList.join("\n"),
|
||||
screenshots,
|
||||
signature1: getSigData(1),
|
||||
signature2: getSigData(2),
|
||||
};
|
||||
}
|
||||
|
||||
function applyData(data) {
|
||||
if (!data) return;
|
||||
document.getElementById("verifierName").value = data.verifier || "";
|
||||
document.getElementById("verifierName").value = data.verifierName || data.verifier || "";
|
||||
DOMAINS.forEach((domain, di) => {
|
||||
domain.items.forEach((item, ii) => {
|
||||
const uid = `${di}-${ii}`;
|
||||
@@ -830,87 +1235,109 @@ function applyData(data) {
|
||||
});
|
||||
if (data.conclusionName) document.getElementById("conclusionName").value = data.conclusionName;
|
||||
if (data.conclusionDate) document.getElementById("conclusionDate").value = data.conclusionDate;
|
||||
if (data.conclusionResult) {
|
||||
const r = document.querySelector(`input[name="conclusion"][value="${data.conclusionResult}"]`);
|
||||
if (r) r.checked = true;
|
||||
|
||||
// Restore screenshots
|
||||
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();
|
||||
}
|
||||
|
||||
function getStorageKey() {
|
||||
const name = document.getElementById("verifierName").value.trim();
|
||||
return name ? STORAGE_PREFIX + name : "";
|
||||
}
|
||||
|
||||
function saveCurrent() {
|
||||
// ========== 保存/加载/提交(API)==========
|
||||
async function saveCurrent() {
|
||||
const name = document.getElementById("verifierName").value.trim();
|
||||
if (!name) { showToast("请先输入验收人姓名"); return; }
|
||||
const data = collectData();
|
||||
localStorage.setItem(STORAGE_PREFIX + name, JSON.stringify(data));
|
||||
updateSavedList();
|
||||
document.getElementById("savedSelect").value = name;
|
||||
showToast(`已保存:${name}`);
|
||||
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();
|
||||
document.getElementById("savedSelect").value = name;
|
||||
showToast(`✅ 已保存:${name}`);
|
||||
} catch (err) { showToast(`保存失败:${err.message}`); }
|
||||
}
|
||||
|
||||
function autoSave() {
|
||||
async function autoSave() {
|
||||
const name = document.getElementById("verifierName").value.trim();
|
||||
if (!name) return;
|
||||
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;
|
||||
const raw = localStorage.getItem(STORAGE_PREFIX + name);
|
||||
if (!raw) { showToast("未找到保存记录"); return; }
|
||||
applyData(JSON.parse(raw));
|
||||
showToast(`已加载:${name}`);
|
||||
try {
|
||||
const res = await apiCall('GET', `/acceptance-tests/${encodeURIComponent(name)}`);
|
||||
applyData(res.data);
|
||||
showToast(`已加载:${name}`);
|
||||
} catch (err) { showToast(`加载失败:${err.message}`); }
|
||||
}
|
||||
|
||||
function deleteSaved() {
|
||||
async function deleteSaved() {
|
||||
const name = document.getElementById("verifierName").value.trim();
|
||||
const sel = document.getElementById("savedSelect").value;
|
||||
const target = name || sel;
|
||||
if (!target) { showToast("请先选择要删除的记录"); return; }
|
||||
if (!confirm(`确认删除「${target}」的验收记录?`)) return;
|
||||
localStorage.removeItem(STORAGE_PREFIX + target);
|
||||
updateSavedList();
|
||||
if (target === name) document.getElementById("verifierName").value = "";
|
||||
showToast(`已删除:${target}`);
|
||||
try {
|
||||
await apiCall('DELETE', `/acceptance-tests/${encodeURIComponent(target)}`);
|
||||
await loadSavedList();
|
||||
if (target === name) document.getElementById("verifierName").value = "";
|
||||
showToast(`已删除:${target}`);
|
||||
} catch (err) { showToast(`删除失败:${err.message}`); }
|
||||
}
|
||||
|
||||
function deleteSavedByName(name) {
|
||||
async function deleteSavedByName(name) {
|
||||
if (!confirm(`确认删除「${name}」的验收记录?`)) return;
|
||||
localStorage.removeItem(STORAGE_PREFIX + name);
|
||||
updateSavedList();
|
||||
showAutocomplete();
|
||||
showToast(`已删除:${name}`);
|
||||
try {
|
||||
await apiCall('DELETE', `/acceptance-tests/${encodeURIComponent(name)}`);
|
||||
await loadSavedList();
|
||||
showAutocomplete();
|
||||
showToast(`已删除:${name}`);
|
||||
} catch (err) { showToast(`删除失败:${err.message}`); }
|
||||
}
|
||||
|
||||
function clearAllSaved() {
|
||||
const keys = Object.keys(localStorage).filter(k => k.startsWith(STORAGE_PREFIX));
|
||||
if (keys.length === 0) { showToast("无已保存记录"); return; }
|
||||
if (!confirm(`确认清空全部 ${keys.length} 条验收记录?此操作不可恢复!`)) return;
|
||||
keys.forEach(k => localStorage.removeItem(k));
|
||||
updateSavedList();
|
||||
showAutocomplete();
|
||||
document.getElementById("verifierName").value = "";
|
||||
showToast(`已清空 ${keys.length} 条记录`);
|
||||
}
|
||||
|
||||
function updateSavedList() {
|
||||
const sel = document.getElementById("savedSelect");
|
||||
const current = sel.value;
|
||||
sel.innerHTML = '<option value="">-- 加载已保存 --</option>';
|
||||
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);
|
||||
sel.innerHTML += `<option value="${name}">${name}</option>`;
|
||||
}
|
||||
}
|
||||
if (current) sel.value = current;
|
||||
async function submitReport() {
|
||||
const name = document.getElementById("verifierName").value.trim();
|
||||
if (!name) { showToast("请先输入验收人姓名"); return; }
|
||||
const data = collectData();
|
||||
if (!data.conclusionResult) { showToast("请先完成至少一项测试"); return; }
|
||||
if (!confirm(`确认提交「${name}」的验收报告?提交后不可再编辑。`)) return;
|
||||
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 apiCall('POST', `/acceptance-tests/${encodeURIComponent(name)}/submit`, {
|
||||
conclusionResult: data.conclusionResult, conclusionIssues: data.conclusionIssues,
|
||||
});
|
||||
await loadSavedList();
|
||||
showToast(`✅ 验收报告已提交:${name}`);
|
||||
} catch (err) { showToast(`提交失败:${err.message}`); }
|
||||
}
|
||||
|
||||
// ========== 下载 ==========
|
||||
@@ -1003,17 +1430,125 @@ function todayStr() {
|
||||
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 ==========
|
||||
function showToast(msg) {
|
||||
const t = document.getElementById("toast");
|
||||
t.textContent = msg;
|
||||
t.classList.add("show");
|
||||
setTimeout(() => t.classList.remove("show"), 2000);
|
||||
setTimeout(() => t.classList.remove("show"), 2500);
|
||||
}
|
||||
|
||||
// ========== 初始化 ==========
|
||||
renderAll();
|
||||
updateSavedList();
|
||||
loadSavedList();
|
||||
|
||||
// 自动填充验收日期
|
||||
const dateEl = document.getElementById("conclusionDate");
|
||||
|
||||
@@ -40,8 +40,10 @@ const AnnualValueReport = lazy(() => import('./pages/tools/AnnualValueReport'))
|
||||
const CalendarPage = lazy(() => import('./pages/Calendar'))
|
||||
const WorkProcess = lazy(() => import('./pages/WorkProcess'))
|
||||
const MyAttendance = lazy(() => import('./pages/portal/MyAttendance'))
|
||||
const MyLeave = lazy(() => import('./pages/portal/MyLeave'))
|
||||
const SpecialStatus = lazy(() => import('./pages/SpecialStatus'))
|
||||
const CompanyFiles = lazy(() => import('./pages/CompanyFiles'))
|
||||
const LeaveApproval = lazy(() => import('./pages/LeaveApproval'))
|
||||
|
||||
// Sprint 4-5 新增页面
|
||||
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="/special-status" element={<ProtectedRoute><AdminLayout><SpecialStatus /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/company-files" element={<ProtectedRoute><AdminLayout><CompanyFiles /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/leave-approval" element={<ProtectedRoute><AdminLayout><LeaveApproval /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/risk-center" element={<ProtectedRoute><AdminLayout><RiskCenter /></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/policies" element={<PortalLayoutWrapper><MyPolicies /></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/home" element={<PortalLayoutWrapper><EmployeeHome /></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" />
|
||||
</button>
|
||||
</div>
|
||||
{/* iframe 加载验收测试页面 */}
|
||||
{/* iframe 加载验收测试页面 — 带版本参数防止缓存 */}
|
||||
<iframe
|
||||
src="/acceptance-test.html"
|
||||
src={`/acceptance-test.html?v=${__APP_VERSION__}`}
|
||||
className="w-full"
|
||||
style={{ height: 'calc(95vh - 42px)', border: 'none' }}
|
||||
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 { HelpCircle, Search, ChevronDown, ChevronRight, Sparkles,
|
||||
Home, Users, FileText, Calculator, Shield, UserX, Bot,
|
||||
Bell, Settings, Lightbulb, AlertTriangle, CheckCircle, Phone } from 'lucide-react'
|
||||
Home, Users, FileText, Calculator, Bot,
|
||||
Settings, Lightbulb, AlertTriangle, CheckCircle, Phone } from 'lucide-react'
|
||||
import Modal from './ui/Modal'
|
||||
import { aiApi } from '../lib/api-services'
|
||||
import clsx from 'clsx'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { toast } from 'sonner'
|
||||
import { surveyApi } from '../lib/api-services'
|
||||
|
||||
@@ -17,6 +17,7 @@ const ROUTE_MAP: Record<string, BreadcrumbItem> = {
|
||||
'/roster': { group: '员工管理', label: '花名册' },
|
||||
'/work-process': { group: '员工管理', label: '用工办理' },
|
||||
'/attendance': { group: '员工管理', label: '考勤确认' },
|
||||
'/leave-approval': { group: '员工管理', label: '休假审批' },
|
||||
'/termination': { group: '员工管理', label: '解聘补偿' },
|
||||
'/special-status': { group: '员工管理', label: '特殊状态' },
|
||||
'/money': { group: '薪税社保', label: '薪税管理' },
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
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'
|
||||
|
||||
const tabItems = [
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Bot, BookMarked,
|
||||
Bell, ScrollText, Settings,
|
||||
ChevronDown, ChevronRight,
|
||||
Building2, CalendarDays, ClipboardList, Heart,
|
||||
Building2, CalendarDays, ClipboardList, Heart, CalendarClock,
|
||||
} from 'lucide-react'
|
||||
import Logo from '../ui/Logo'
|
||||
|
||||
@@ -33,7 +33,7 @@ const navGroups: NavGroup[] = [
|
||||
title: '首页',
|
||||
items: [
|
||||
{ path: '/', label: '工作台', icon: LayoutDashboard },
|
||||
{ path: '/calendar', label: '工作日历', icon: CalendarDays },
|
||||
{ path: '/calendar', label: '日历', icon: CalendarDays },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -57,6 +57,7 @@ const navGroups: NavGroup[] = [
|
||||
title: '时间',
|
||||
items: [
|
||||
{ path: '/attendance', label: '考勤排班', icon: CalendarCheck },
|
||||
{ path: '/leave-approval', label: '休假审批', icon: CalendarClock },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -66,14 +67,14 @@ const navGroups: NavGroup[] = [
|
||||
{ path: '/evidence', label: '证据链', icon: FileSearch },
|
||||
{ path: '/policies', label: '规章制度', icon: FileText },
|
||||
{ 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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '更多',
|
||||
items: [
|
||||
{ path: '/ai-assistant', label: 'AI 顾问', icon: Bot },
|
||||
{ path: '/ai-assistant', label: 'AI顾问', icon: Bot },
|
||||
{ path: '/templates', label: '文本模板', icon: BookMarked },
|
||||
{ path: '/notifications', label: '通知管理', icon: Bell },
|
||||
{ path: '/audit', label: '操作日志', icon: ScrollText },
|
||||
|
||||
@@ -24,6 +24,7 @@ const QUICK_PAGES: SearchResult[] = [
|
||||
{ type: 'page', id: 'social', title: '社保公积金', link: '/social', icon: 'shield' },
|
||||
{ type: 'page', id: 'termination', title: '离职管理', link: '/termination', icon: 'userX' },
|
||||
{ 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: 'salary-dashboard', title: '薪酬分析', link: '/salary-dashboard', icon: 'chart' },
|
||||
{ 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 }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, errorInfo)
|
||||
componentDidCatch(_error: Error, _errorInfo: React.ErrorInfo) {
|
||||
// 错误已通过 getDerivedStateFromError 捕获并展示降级 UI
|
||||
}
|
||||
|
||||
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 步骤条组件 — 用于多步骤工作流引导
|
||||
* 支持横向/纵向布局、可点击步骤导航、完成/当前/待办状态
|
||||
*/
|
||||
import { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { Check } from 'lucide-react'
|
||||
|
||||
@@ -24,7 +23,6 @@ interface StepperProps {
|
||||
* Stepper 步骤条 — 展示工作流进度和步骤导航
|
||||
*/
|
||||
export function Stepper({ steps, orientation = 'horizontal', onStepClick, className }: StepperProps) {
|
||||
const currentIndex = steps.findIndex(s => s.status === 'current')
|
||||
|
||||
if (orientation === 'vertical') {
|
||||
return (
|
||||
|
||||
@@ -25,10 +25,10 @@ export const authApi = {
|
||||
login: (data: { phone: string; password: string }) =>
|
||||
post('/auth/login', data).then(unwrap<any>()),
|
||||
/** 注册 */
|
||||
register: (data: any) =>
|
||||
register: (data: Record<string, unknown>) =>
|
||||
post('/auth/register', data).then(unwrap<any>()),
|
||||
/** 平台登录 */
|
||||
platformLogin: (data: any) =>
|
||||
platformLogin: (data: Record<string, unknown>) =>
|
||||
post('/auth/platform-login', data).then(unwrap<any>()),
|
||||
/** 发送忘记密码验证码 */
|
||||
forgotPasswordSendCode: (phone: string) =>
|
||||
@@ -64,19 +64,19 @@ export const employeeApi = {
|
||||
detail: (id: string) =>
|
||||
get(`/employees/${id}`).then(unwrap<any>()),
|
||||
/** 创建员工 */
|
||||
create: (data: any) =>
|
||||
create: (data: Record<string, unknown>) =>
|
||||
post('/employees', data),
|
||||
/** 更新员工 */
|
||||
update: (id: string, data: any) =>
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
put(`/employees/${id}`, data),
|
||||
/** 删除员工 */
|
||||
remove: (id: string) =>
|
||||
del(`/employees/${id}`),
|
||||
/** 重新入职 */
|
||||
rehire: (id: string, data: any) =>
|
||||
rehire: (id: string, data: Record<string, unknown>) =>
|
||||
post(`/employees/${id}/rehire`, data),
|
||||
/** 添加合同 */
|
||||
addContract: (data: any) =>
|
||||
addContract: (data: Record<string, unknown>) =>
|
||||
post('/employees/contracts', data),
|
||||
/** 删除合同 */
|
||||
removeContract: (contractId: string) =>
|
||||
@@ -101,7 +101,7 @@ export interface RosterParams {
|
||||
}
|
||||
|
||||
export interface RosterResponse {
|
||||
data: any[]
|
||||
data: Record<string, unknown>[]
|
||||
pagination: { page: number; pageSize: number; total: number; totalPages: number }
|
||||
globalRiskStats?: { expiring: number; expired: number; unsigned: number }
|
||||
}
|
||||
@@ -129,31 +129,31 @@ export const rosterApi = {
|
||||
profile: (employeeId: string) =>
|
||||
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),
|
||||
/** 调岗 */
|
||||
departmentChange: (employeeId: string, data: any) =>
|
||||
departmentChange: (employeeId: string, data: Record<string, unknown>) =>
|
||||
post(`/roster/${employeeId}/department-change`, data),
|
||||
/** 考勤记录 */
|
||||
attendance: (employeeId: string, data: any) =>
|
||||
attendance: (employeeId: string, data: Record<string, unknown>) =>
|
||||
post(`/roster/${employeeId}/attendance`, data),
|
||||
/** 删除考勤记录 */
|
||||
removeAttendance: (employeeId: string, id: string) =>
|
||||
del(`/roster/${employeeId}/attendance/${id}`),
|
||||
/** 培训记录 */
|
||||
training: (employeeId: string, data: any) =>
|
||||
training: (employeeId: string, data: Record<string, unknown>) =>
|
||||
post(`/roster/${employeeId}/training`, data),
|
||||
/** 删除培训记录 */
|
||||
removeTraining: (employeeId: string, id: string) =>
|
||||
del(`/roster/${employeeId}/training/${id}`),
|
||||
/** 绩效记录 */
|
||||
performance: (employeeId: string, data: any) =>
|
||||
performance: (employeeId: string, data: Record<string, unknown>) =>
|
||||
post(`/roster/${employeeId}/performance`, data),
|
||||
/** 删除绩效记录 */
|
||||
removePerformance: (employeeId: string, id: string) =>
|
||||
del(`/roster/${employeeId}/performance/${id}`),
|
||||
/** 违纪记录-创建 */
|
||||
createDisciplinary: (employeeId: string, data: any) =>
|
||||
createDisciplinary: (employeeId: string, data: Record<string, unknown>) =>
|
||||
post(`/roster/${employeeId}/disciplinary`, data),
|
||||
/** 删除违纪记录 */
|
||||
removeDisciplinary: (employeeId: string, id: string) =>
|
||||
@@ -164,7 +164,7 @@ export const rosterApi = {
|
||||
|
||||
export const attachmentApi = {
|
||||
/** 添加附件 */
|
||||
add: (data: any) =>
|
||||
add: (data: Record<string, unknown>) =>
|
||||
post('/attachments', data),
|
||||
/** 获取附件列表 */
|
||||
list: (employeeId: string) =>
|
||||
@@ -265,25 +265,42 @@ export const attendanceApi = {
|
||||
confirm: (data: { employeeId: string; month: string }) =>
|
||||
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),
|
||||
/** 删除班次 */
|
||||
removeShift: (id: string) =>
|
||||
del(`/attendance/shifts/${id}`),
|
||||
/** 批量排班 */
|
||||
batchAssign: (items: any[]) =>
|
||||
batchAssign: (items: Record<string, unknown>[]) =>
|
||||
post('/attendance/shift-assignments/batch', { items }),
|
||||
/** 删除排班 */
|
||||
removeAssignment: (id: string) =>
|
||||
del(`/attendance/shift-assignments/${id}`),
|
||||
/** 创建请假记录 */
|
||||
createLeave: (data: any) =>
|
||||
createLeave: (data: Record<string, unknown>) =>
|
||||
post('/attendance/leaves', data),
|
||||
/** 删除请假记录 */
|
||||
removeLeave: (id: string) =>
|
||||
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 相关 ==========
|
||||
|
||||
export const aiApi = {
|
||||
@@ -294,19 +311,19 @@ export const aiApi = {
|
||||
conversation: (id: string) =>
|
||||
get(`/ai/conversations/${id}`).then(unwrap<any>()),
|
||||
/** 创建对话 */
|
||||
createConversation: (data: any) =>
|
||||
createConversation: (data: Record<string, unknown>) =>
|
||||
post('/ai/conversations', data).then(unwrap<any>()),
|
||||
/** 更新对话 */
|
||||
updateConversation: (id: string, data: any) =>
|
||||
updateConversation: (id: string, data: Record<string, unknown>) =>
|
||||
put(`/ai/conversations/${id}`, data),
|
||||
/** 删除对话 */
|
||||
removeConversation: (id: string) =>
|
||||
del(`/ai/conversations/${id}`),
|
||||
/** AI 咨询 */
|
||||
consult: (data: any) =>
|
||||
consult: (data: Record<string, unknown>) =>
|
||||
post('/ai/consultation', data).then(unwrap<any>()),
|
||||
/** AI 上下文问答 */
|
||||
contextAsk: (data: any) =>
|
||||
contextAsk: (data: Record<string, unknown>) =>
|
||||
post('/ai/context-ask', data).then(unwrap<any>()),
|
||||
/** 合同审查上传 */
|
||||
reviewUpload: (formData: FormData) =>
|
||||
@@ -315,19 +332,19 @@ export const aiApi = {
|
||||
review: (contractText: string) =>
|
||||
post('/ai/review', { contractText }).then(unwrap<any>()),
|
||||
/** 保存审查结果 */
|
||||
reviewSave: (data: any) =>
|
||||
reviewSave: (data: Record<string, unknown>) =>
|
||||
post('/ai/review/save', data),
|
||||
/** 案例匹配 */
|
||||
matchCase: (scenario: string) =>
|
||||
post('/ai/match-case', { scenario }).then(unwrap<any>()),
|
||||
/** 案例转待办 */
|
||||
caseToTodo: (data: any) =>
|
||||
caseToTodo: (data: Record<string, unknown>) =>
|
||||
post('/ai/case-to-todo', data),
|
||||
/** RAG 知识库列表 */
|
||||
ragList: () =>
|
||||
get('/ai/rag/list').then(unwrap<any[]>()),
|
||||
/** RAG 添加知识 */
|
||||
ragAdd: (data: any) =>
|
||||
ragAdd: (data: Record<string, unknown>) =>
|
||||
post('/ai/rag/add', data),
|
||||
/** RAG 删除知识 */
|
||||
ragRemove: (id: string) =>
|
||||
@@ -347,10 +364,10 @@ export const payrollApi = {
|
||||
template: () =>
|
||||
get('/payroll2/template').then(unwrap<any[]>()),
|
||||
/** 创建模版项 */
|
||||
createTemplateItem: (data: any) =>
|
||||
createTemplateItem: (data: Record<string, unknown>) =>
|
||||
post('/payroll2/template', data),
|
||||
/** 更新模版项 */
|
||||
updateTemplateItem: (id: string, data: any) =>
|
||||
updateTemplateItem: (id: string, data: Record<string, unknown>) =>
|
||||
put(`/payroll2/template/${id}`, data),
|
||||
/** 删除模版项 */
|
||||
removeTemplateItem: (id: string) =>
|
||||
@@ -359,7 +376,7 @@ export const payrollApi = {
|
||||
batchCheck: (month: string) =>
|
||||
get('/payroll2/batches/check', { params: { month } }).then(unwrap<any>()),
|
||||
/** 批次列表 */
|
||||
batches: (params?: any) =>
|
||||
batches: (params?: Record<string, unknown>) =>
|
||||
get('/payroll2/batches', { params }).then(unwrap<any[]>()),
|
||||
/** 归档批次列表 */
|
||||
archivedBatches: () =>
|
||||
@@ -368,7 +385,7 @@ export const payrollApi = {
|
||||
batchDetail: (id: string) =>
|
||||
get(`/payroll2/batches/${id}`).then(unwrap<any>()),
|
||||
/** 创建批次 */
|
||||
createBatch: (data: any) =>
|
||||
createBatch: (data: Record<string, unknown>) =>
|
||||
post('/payroll2/batches', data),
|
||||
/** 重命名批次 */
|
||||
renameBatch: (batchId: string, name: string) =>
|
||||
@@ -395,8 +412,11 @@ export const payrollApi = {
|
||||
removeBatchEmployee: (batchId: string, employeeId: string) =>
|
||||
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),
|
||||
/** 获取条目个税计算明细 */
|
||||
taxDetail: (batchId: string, employeeId: string) =>
|
||||
get(`/payroll2/batches/${batchId}/entries/${employeeId}/tax-detail`).then(unwrap<any>()),
|
||||
/** 算薪前 AI 校验 */
|
||||
preCheck: (batchId: string) =>
|
||||
get(`/payroll2/batches/${batchId}/pre-check`).then(unwrap<any>()),
|
||||
@@ -407,13 +427,13 @@ export const payrollApi = {
|
||||
overtimeRecords: (params: { month?: string; employeeId?: string }) =>
|
||||
get('/payroll/overtime', { params }).then(unwrap<any[]>()),
|
||||
/** 保存加班费记录 */
|
||||
saveOvertime: (data: any) =>
|
||||
saveOvertime: (data: Record<string, unknown>) =>
|
||||
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>()),
|
||||
/** 批量导入加班工时 */
|
||||
batchImportOvertime: (data: any[]) =>
|
||||
batchImportOvertime: (data: Record<string, unknown>[]) =>
|
||||
post('/payroll/overtime/batch', data),
|
||||
/** 导入加班费到批次 */
|
||||
importOvertimeToBatch: (batchId: string) =>
|
||||
@@ -422,19 +442,19 @@ export const payrollApi = {
|
||||
overtimeConfig: () =>
|
||||
get('/payroll/overtime/config').then(unwrap<any>()),
|
||||
/** 保存加班费配置 */
|
||||
saveOvertimeConfig: (data: any) =>
|
||||
saveOvertimeConfig: (data: Record<string, unknown>) =>
|
||||
post('/payroll/overtime/config', data),
|
||||
/** 工资条列表 */
|
||||
payslips: (params: { month?: string; employeeId?: string }) =>
|
||||
get('/payroll/payslip', { params }).then(unwrap<any[]>()),
|
||||
/** 创建/更新工资条 */
|
||||
savePayslip: (data: any) =>
|
||||
savePayslip: (data: Record<string, unknown>) =>
|
||||
post('/payroll/payslip', data).then(unwrap<any>()),
|
||||
/** 删除工资条 */
|
||||
removePayslip: (id: string) =>
|
||||
del(`/payroll/payslip/${id}`),
|
||||
/** 个税试算 */
|
||||
taxPreview: (data: any) =>
|
||||
taxPreview: (data: Record<string, unknown>) =>
|
||||
post('/payroll/tax-preview', data).then(unwrap<any>()),
|
||||
/** 薪资汇总表 */
|
||||
batchSummary: (id: string) =>
|
||||
@@ -470,10 +490,10 @@ export const socialInsuranceApi = {
|
||||
housingConfigVersions: (city?: string) =>
|
||||
get('/social/housing-config/versions', { params: city ? { city } : {} }).then(unwrap<any[]>()),
|
||||
/** 创建社保配置版本 */
|
||||
createConfigVersion: (data: any) =>
|
||||
createConfigVersion: (data: Record<string, unknown>) =>
|
||||
post('/social/config/versions', data),
|
||||
/** 创建公积金配置版本 */
|
||||
createHousingConfigVersion: (data: any) =>
|
||||
createHousingConfigVersion: (data: Record<string, unknown>) =>
|
||||
post('/social/housing-config/versions', data),
|
||||
/** 社保计算 */
|
||||
calculate: (base: number, city: string) =>
|
||||
@@ -491,10 +511,10 @@ export const socialInsuranceApi = {
|
||||
housingAdjustPreview: (configId: string) =>
|
||||
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),
|
||||
/** 应用公积金调整 */
|
||||
applyHousingAdjust: (configId: string, data: any) =>
|
||||
applyHousingAdjust: (configId: string, data: Record<string, unknown>) =>
|
||||
post(`/social/housing-config/${configId}/adjust-apply`, data),
|
||||
/** 重置调整 */
|
||||
resetAdjust: (configId: string, city: string) =>
|
||||
@@ -509,19 +529,19 @@ export const socialInsuranceApi = {
|
||||
monthlyProcessStatus: (month: string) =>
|
||||
get('/social/monthly-process/status', { params: { month } }).then(unwrap<any>()),
|
||||
/** 完成月度办理 */
|
||||
completeMonthlyProcess: (data: any) =>
|
||||
completeMonthlyProcess: (data: Record<string, unknown>) =>
|
||||
post('/social/monthly-process/complete', data),
|
||||
/** 专项附加扣除批量 */
|
||||
specialDeductionBatch: (month: string) =>
|
||||
get('/social/special-deduction/batch', { params: { month } }).then(unwrap<any[]>()),
|
||||
/** 保存专项附加扣除 */
|
||||
saveSpecialDeduction: (data: any) =>
|
||||
saveSpecialDeduction: (data: Record<string, unknown>) =>
|
||||
post('/social/special-deduction', data),
|
||||
/** 活跃申报员工 */
|
||||
activeDeclaration: (month: string) =>
|
||||
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>()),
|
||||
/** 社保月度变动 */
|
||||
monthlyChanges: (month: string) =>
|
||||
@@ -544,7 +564,7 @@ export const commercialInsuranceApi = {
|
||||
enrollments: (planId: string) =>
|
||||
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),
|
||||
/** 删除方案 */
|
||||
removePlan: (id: string) =>
|
||||
@@ -555,16 +575,16 @@ export const commercialInsuranceApi = {
|
||||
|
||||
export const terminationApi = {
|
||||
/** 创建离职 */
|
||||
create: (data: any) =>
|
||||
create: (data: Record<string, unknown>) =>
|
||||
post('/termination', data),
|
||||
/** 创建草稿 */
|
||||
createDraft: (data: any) =>
|
||||
createDraft: (data: Record<string, unknown>) =>
|
||||
post('/termination/draft', data),
|
||||
/** 更新草稿 */
|
||||
updateDraft: (draftId: string, data: any) =>
|
||||
updateDraft: (draftId: string, data: Record<string, unknown>) =>
|
||||
put(`/termination/draft/${draftId}`, data),
|
||||
/** 草稿列表 */
|
||||
drafts: (params: any) =>
|
||||
drafts: (params: Record<string, unknown>) =>
|
||||
get('/termination/drafts', { params }).then(unwrap<any>()),
|
||||
/** 草稿详情 */
|
||||
detail: (draftId: string) =>
|
||||
@@ -594,7 +614,7 @@ export const terminationApi = {
|
||||
assess: (employeeId: string, reason: string) =>
|
||||
get(`/termination/assess/${employeeId}`, { params: { reason } }).then(unwrap<any>()),
|
||||
/** 批量预览 */
|
||||
batchPreview: (items: any[]) =>
|
||||
batchPreview: (items: Record<string, unknown>[]) =>
|
||||
post('/termination/batch/preview', { items }),
|
||||
}
|
||||
|
||||
@@ -608,10 +628,10 @@ export const policiesApi = {
|
||||
detail: (id: string) =>
|
||||
get(`/policies/${id}`).then(unwrap<any>()),
|
||||
/** 创建制度 */
|
||||
create: (data: any) =>
|
||||
create: (data: Record<string, unknown>) =>
|
||||
post('/policies', data),
|
||||
/** 更新制度 */
|
||||
update: (id: string, data: any) =>
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
put(`/policies/${id}`, data),
|
||||
/** 推进民主程序 */
|
||||
advanceStep: (id: string, step: number, note?: string) =>
|
||||
@@ -653,7 +673,7 @@ export const calendarApi = {
|
||||
events: (month: string) =>
|
||||
get(`/calendar?month=${month}`).then(unwrap<any[]>()),
|
||||
/** 创建事件 */
|
||||
createEvent: (data: any) =>
|
||||
createEvent: (data: Record<string, unknown>) =>
|
||||
post('/calendar', data),
|
||||
/** 删除事件 */
|
||||
removeEvent: (id: string) =>
|
||||
@@ -667,7 +687,7 @@ export const companyFilesApi = {
|
||||
list: (params?: { fileType?: string }) =>
|
||||
get('/company-files', { params }).then(unwrap<any[]>()),
|
||||
/** 添加文件 */
|
||||
add: (data: any) =>
|
||||
add: (data: Record<string, unknown>) =>
|
||||
post('/company-files', data),
|
||||
/** 删除文件 */
|
||||
remove: (id: string) =>
|
||||
@@ -684,7 +704,7 @@ export const notificationsApi = {
|
||||
settings: () =>
|
||||
get('/notifications/settings').then(unwrap<any>()),
|
||||
/** 更新通知设置 */
|
||||
updateSettings: (data: any) =>
|
||||
updateSettings: (data: Record<string, unknown>) =>
|
||||
put('/notifications/settings', data),
|
||||
/** 检查合同到期 */
|
||||
checkContracts: () =>
|
||||
@@ -701,16 +721,16 @@ export const settingsApi = {
|
||||
org: () =>
|
||||
get('/settings/org').then(unwrap<any>()),
|
||||
/** 更新组织设置 */
|
||||
updateOrg: (data: any) =>
|
||||
updateOrg: (data: Record<string, unknown>) =>
|
||||
put('/settings/org', data),
|
||||
/** 用户列表 */
|
||||
users: () =>
|
||||
get('/settings/users').then(unwrap<any>()),
|
||||
/** 添加用户 */
|
||||
addUser: (data: any) =>
|
||||
addUser: (data: Record<string, unknown>) =>
|
||||
post('/settings/users', data),
|
||||
/** 更新用户 */
|
||||
updateUser: (id: string, data: any) =>
|
||||
updateUser: (id: string, data: Record<string, unknown>) =>
|
||||
put(`/settings/users/${id}`, data),
|
||||
/** 切换用户禁用状态 */
|
||||
toggleDisable: (id: string) =>
|
||||
@@ -739,7 +759,7 @@ export const templatesApi = {
|
||||
detail: (id: string) =>
|
||||
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>()),
|
||||
/** 企业模板列表 */
|
||||
enterpriseList: (params: { page?: number; pageSize?: number; category?: string }) =>
|
||||
@@ -748,13 +768,13 @@ export const templatesApi = {
|
||||
enterpriseDetail: (id: string) =>
|
||||
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),
|
||||
/** 删除企业模板 */
|
||||
removeEnterprise: (id: string) =>
|
||||
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>()),
|
||||
}
|
||||
|
||||
@@ -768,7 +788,7 @@ export const workProcessApi = {
|
||||
detail: (id: string) =>
|
||||
get(`/work-processes/${id}`).then(unwrap<any>()),
|
||||
/** 创建 */
|
||||
create: (data: any) =>
|
||||
create: (data: Record<string, unknown>) =>
|
||||
post('/work-processes', data).then(unwrap<any>()),
|
||||
/** 提交 */
|
||||
submit: (id: string) =>
|
||||
@@ -794,10 +814,10 @@ export const specialStatusApi = {
|
||||
stats: () =>
|
||||
get('/special-statuses/stats/overview').then(unwrap<any>()),
|
||||
/** 创建 */
|
||||
create: (data: any) =>
|
||||
create: (data: Record<string, unknown>) =>
|
||||
post('/special-statuses', data),
|
||||
/** 更新 */
|
||||
update: (id: string, data: any) =>
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
put(`/special-statuses/${id}`, data),
|
||||
/** 删除 */
|
||||
remove: (id: string) =>
|
||||
@@ -816,7 +836,7 @@ export const searchApi = {
|
||||
|
||||
export const surveyApi = {
|
||||
/** 提交问卷 */
|
||||
submit: (items: any[]) =>
|
||||
submit: (items: Record<string, unknown>[]) =>
|
||||
post('/survey/submit', { items }),
|
||||
}
|
||||
|
||||
@@ -833,13 +853,13 @@ export const platformApi = {
|
||||
orgDetail: (id: string) =>
|
||||
get(`/platform/orgs/${id}`).then(unwrap<any>()),
|
||||
/** 创建组织 */
|
||||
createOrg: (data: any) =>
|
||||
createOrg: (data: Record<string, unknown>) =>
|
||||
post('/platform/orgs', data),
|
||||
/** 更新组织 */
|
||||
updateOrg: (id: string, data: any) =>
|
||||
updateOrg: (id: string, data: Record<string, unknown>) =>
|
||||
put(`/platform/orgs/${id}`, data),
|
||||
/** 更新组织管理员 */
|
||||
updateOrgAdmin: (id: string, data: any) =>
|
||||
updateOrgAdmin: (id: string, data: Record<string, unknown>) =>
|
||||
put(`/platform/orgs/${id}/admin`, data),
|
||||
/** 删除组织 */
|
||||
removeOrg: (id: string) =>
|
||||
@@ -914,7 +934,7 @@ export const portalApi = {
|
||||
onboardingInfo: (token: string) =>
|
||||
portalGet(`/onboarding/${token}`).then(unwrap<any>()),
|
||||
/** 入职提交 */
|
||||
onboardingSubmit: (data: any) =>
|
||||
onboardingSubmit: (data: Record<string, unknown>) =>
|
||||
portalPost('/onboarding', data),
|
||||
/** 入职上传文件 */
|
||||
onboardingUpload: (token: string, formData: FormData) =>
|
||||
@@ -935,9 +955,18 @@ export const portalApi = {
|
||||
resignationStatus: () =>
|
||||
portalGet('/resignation/status').then(unwrap<any[]>()),
|
||||
/** 提交离职申请 */
|
||||
resignationSubmit: (data: any) =>
|
||||
resignationSubmit: (data: Record<string, unknown>) =>
|
||||
portalPost('/resignation/submit', data).then(unwrap<any>()),
|
||||
/** 撤回离职申请 */
|
||||
resignationWithdraw: (id: string) =>
|
||||
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 refreshQueue: Array<(token: string) => void> = []
|
||||
|
||||
function onTokenRefreshed(token: string) {
|
||||
refreshQueue.forEach((cb) => cb(token))
|
||||
refreshQueue = []
|
||||
}
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
@@ -24,7 +30,16 @@ api.interceptors.response.use(
|
||||
const originalRequest = error.config
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
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
|
||||
try {
|
||||
const refreshToken = useAuthStore.getState().refreshToken
|
||||
@@ -32,9 +47,11 @@ api.interceptors.response.use(
|
||||
const res = await axios.post(`${API_BASE}/auth/refresh`, { refreshToken })
|
||||
const newToken = res.data.data.accessToken
|
||||
useAuthStore.getState().updateToken(newToken)
|
||||
onTokenRefreshed(newToken)
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
return api(originalRequest)
|
||||
} catch {
|
||||
refreshQueue = []
|
||||
useAuthStore.getState().logout()
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(error)
|
||||
|
||||
@@ -3,7 +3,7 @@ import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App'
|
||||
import ErrorBoundary from './components/ErrorBoundary'
|
||||
import ErrorBoundary from './components/ui/ErrorBoundary'
|
||||
import { ConfirmProvider } from './hooks/useConfirm'
|
||||
import './index.css'
|
||||
|
||||
|
||||
+17
-1999
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 EmptyState from '../components/ui/EmptyState'
|
||||
import { InlineAlert } from '../components/ui/InlineAlert'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; icon: typeof CheckCircle }> = {
|
||||
@@ -505,6 +506,7 @@ function ConfirmTab() {
|
||||
// ========== 班次管理 Tab ==========
|
||||
function ShiftsTab() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [editShift, setEditShift] = useState<any>(null)
|
||||
const [form, setForm] = useState({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' })
|
||||
@@ -543,6 +545,9 @@ function ShiftsTab() {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<PageGuide>
|
||||
班次管理用于定义考勤班次(如早班、晚班、全天班)。设置班次名称、上下班时间、弹性时间等。排班时引用此处定义的班次。
|
||||
</PageGuide>
|
||||
<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) }}>
|
||||
<Plus className="w-4 h-4 mr-1" />新增班次
|
||||
@@ -564,7 +569,7 @@ function ShiftsTab() {
|
||||
</div>
|
||||
<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-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 className="mt-2 text-xs text-gray-500 space-y-0.5">
|
||||
@@ -685,6 +690,9 @@ function ScheduleTab() {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<PageGuide>
|
||||
排班用于按日期为员工分配班次。选择日期后可查看当日排班情况,点击「排班」按钮为员工分配班次。支持批量排班和复制排班。
|
||||
</PageGuide>
|
||||
<div className="flex items-center justify-between">
|
||||
<input
|
||||
type="date"
|
||||
@@ -809,6 +817,9 @@ function DailyTab() {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<PageGuide>
|
||||
每日出勤记录展示当日所有员工的打卡情况,包括上班/下班时间、迟到/早退/缺卡状态。可手动补卡或修正异常记录。
|
||||
</PageGuide>
|
||||
<div className="flex justify-end">
|
||||
<input
|
||||
type="date"
|
||||
@@ -890,6 +901,9 @@ function MonthlyTab() {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<PageGuide>
|
||||
月度报表汇总当月各员工的出勤天数、迟到次数、加班时长等统计数据。可导出月度考勤表用于发薪参考。
|
||||
</PageGuide>
|
||||
<div className="flex items-center justify-between">
|
||||
<input
|
||||
type="month"
|
||||
@@ -954,6 +968,7 @@ function MonthlyTab() {
|
||||
// ========== 休假记录 Tab ==========
|
||||
function LeavesTab() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [form, setForm] = useState({ employeeId: '', leaveType: 'PERSONAL', startDate: '', endDate: '', days: 1, reason: '', remark: '' })
|
||||
|
||||
@@ -994,6 +1009,9 @@ function LeavesTab() {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<PageGuide>
|
||||
休假记录管理员工的请假信息,包括事假、病假、年假等。可手动录入休假记录,系统自动计算天数并关联考勤数据。
|
||||
</PageGuide>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setShowAdd(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" />新增休假记录
|
||||
@@ -1025,7 +1043,7 @@ function LeavesTab() {
|
||||
</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" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
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 Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useMemo } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { CalendarDays, Plus, Trash2, ChevronLeft, ChevronRight, X, MapPin, User } from 'lucide-react'
|
||||
import { CalendarDays, Plus, Trash2, ChevronLeft, ChevronRight, X } from 'lucide-react'
|
||||
import { dashboardApi, calendarApi } from '../lib/api-services'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef } from 'react'
|
||||
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 { rosterApi, employeeApi, attachmentApi } from '../lib/api-services'
|
||||
import Card from '../components/ui/Card'
|
||||
|
||||
+714
-502
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
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 Card from '../components/ui/Card'
|
||||
import EmptyState from '../components/ui/EmptyState'
|
||||
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 [pageSize, setPageSize] = useState(20)
|
||||
|
||||
const { data: listData, isLoading } = useQuery<any>({
|
||||
const { data: listData, isLoading, isError, error, refetch } = useQuery<any>({
|
||||
queryKey: ['evidence', refType, page, pageSize],
|
||||
queryFn: async () => {
|
||||
const params: any = { page, pageSize }
|
||||
@@ -35,6 +37,9 @@ export default function Evidence() {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<PageGuide>
|
||||
证据链管理记录系统中所有关键操作的不可篡改证据,用于劳动仲裁举证。支持按关联类型筛选、验证证据完整性。点击「验证全部完整性」可批量校验所有证据是否被篡改。
|
||||
</PageGuide>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -82,6 +87,8 @@ export default function Evidence() {
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||||
) : isError ? (
|
||||
<QueryError error={error} onRetry={refetch} />
|
||||
) : !list || list.length === 0 ? (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
+12
-2088
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 }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [form, setForm] = useState({
|
||||
contractExpiry: settings.contractExpiry ?? true,
|
||||
expiryDays: settings.expiryDays ?? 30,
|
||||
|
||||
@@ -7,6 +7,8 @@ import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import EmptyState from '../components/ui/EmptyState'
|
||||
import Pagination from '../components/ui/Pagination'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
import QueryError from '../components/ui/QueryError'
|
||||
|
||||
const STEP_LABELS: Record<string, string> = {
|
||||
DRAFTING: '起草',
|
||||
@@ -27,7 +29,7 @@ export default function Policies() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
const { data: listData, isLoading } = useQuery<any>({
|
||||
const { data: listData, isLoading, isError, error, refetch } = useQuery<any>({
|
||||
queryKey: ['policies', page, pageSize],
|
||||
queryFn: async () => {
|
||||
return await policiesApi.list({ page, pageSize })
|
||||
@@ -51,6 +53,9 @@ export default function Policies() {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<PageGuide>
|
||||
规章制度管理遵循民主程序四步法:起草 → 讨论 → 协商 → 公示。每一步均需记录参与人员、会议纪要及员工签字,确保制度合法有效。新建制度后按步骤推进,系统自动生成民主程序履行记录。
|
||||
</PageGuide>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -66,6 +71,8 @@ export default function Policies() {
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||||
) : isError ? (
|
||||
<QueryError error={error} onRetry={refetch} />
|
||||
) : !list || list.length === 0 ? (
|
||||
<EmptyState title="暂无规章制度" description="点击右上角新建制度" />
|
||||
) : (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
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 { useAuthStore } from '../store/authStore'
|
||||
import { useDebouncedValue } from '../hooks/useDebouncedValue'
|
||||
@@ -12,11 +12,13 @@ import Button from '../components/ui/Button'
|
||||
import { Input, Label, Select } from '../components/ui/Input'
|
||||
import Modal from '../components/ui/Modal'
|
||||
import Pagination from '../components/ui/Pagination'
|
||||
import { 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 { InlineAlert } from '../components/ui/InlineAlert'
|
||||
import { AddEmployeeModal, ResignModal, RehireModal, SalaryChangeModal, DeptChangeModal } from './roster/modals'
|
||||
import { ImportSettings } from './Settings'
|
||||
import QueryError from '../components/ui/QueryError'
|
||||
|
||||
export default function Roster() {
|
||||
const queryClient = useQueryClient()
|
||||
@@ -49,7 +51,7 @@ export default function Roster() {
|
||||
const [batchTerminateReason, setBatchTerminateReason] = useState('NEGOTIATED')
|
||||
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],
|
||||
queryFn: async () => {
|
||||
const params: any = { page, pageSize }
|
||||
@@ -272,6 +274,9 @@ export default function Roster() {
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageGuide>
|
||||
员工花名册统一管理所有员工档案信息,包括基本信息、合同、薪酬、社保等。支持搜索、筛选、导出。点击员工可查看详细档案。支持入职登记、离职办理、合同签订等操作。
|
||||
</PageGuide>
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -407,6 +412,8 @@ export default function Roster() {
|
||||
|
||||
{isLoading ? (
|
||||
<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 ? (
|
||||
<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 { Select } from '../components/ui/Input'
|
||||
import { InlineAlert } from '../components/ui/InlineAlert'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
import QueryError from '../components/ui/QueryError'
|
||||
import { salaryDashboardApi } from '../lib/api-services'
|
||||
|
||||
/** 金额格式化 */
|
||||
@@ -18,7 +20,7 @@ export default function SalaryDashboard() {
|
||||
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],
|
||||
queryFn: async () => {
|
||||
return await salaryDashboardApi.data(Number(year))
|
||||
@@ -37,6 +39,9 @@ export default function SalaryDashboard() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageGuide>
|
||||
薪酬分析看板展示企业薪酬分布、部门间薪酬对比及年度趋势变化。选择年份后查看各部门薪酬数据、同比环比变化。用于辅助薪酬决策和成本控制。
|
||||
</PageGuide>
|
||||
{/* 页头 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -55,6 +60,8 @@ export default function SalaryDashboard() {
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
) : isError ? (
|
||||
<QueryError error={error} onRetry={refetch} />
|
||||
) : !data ? (
|
||||
<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 { Input, Label, Select } from '../components/ui/Input'
|
||||
import Modal from '../components/ui/Modal'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
|
||||
|
||||
export default function Settings() {
|
||||
@@ -618,6 +619,7 @@ function handleAcceptanceExport() {
|
||||
|
||||
function PlanSettings({ orgData }: { orgData: any }) {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const plan = orgData?.plan || 'FREE'
|
||||
|
||||
const { data: usageData } = useQuery<any>({
|
||||
@@ -683,8 +685,8 @@ function PlanSettings({ orgData }: { orgData: any }) {
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm(`确定切换到${p.label}?`)) planMutation.mutate(p.key)
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: '切换套餐', message: `确定切换到${p.label}?`, variant: 'primary' })) planMutation.mutate(p.key)
|
||||
}}
|
||||
disabled={planMutation.isPending}
|
||||
>
|
||||
@@ -959,8 +961,14 @@ function InitImport() {
|
||||
const data = await res.json()
|
||||
if (!data.success) {
|
||||
setError(data.error?.message || '导入失败')
|
||||
toast.error(data.error?.message || '导入失败')
|
||||
} else {
|
||||
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: ['dashboard'] })
|
||||
}
|
||||
@@ -1123,6 +1131,22 @@ function InitImport() {
|
||||
{result.errors.length > 10 && <div className="text-amber-600">...还有 {result.errors.length - 10} 条,请导出错误日志查看全部</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>
|
||||
)}
|
||||
|
||||
@@ -1175,8 +1199,14 @@ function MonthlyImport() {
|
||||
const data = await res.json()
|
||||
if (!data.success) {
|
||||
setError(data.error?.message || '导入失败')
|
||||
toast.error(data.error?.message || '导入失败')
|
||||
} else {
|
||||
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: ['dashboard'] })
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
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 { socialInsuranceApi, commercialInsuranceApi } from '../lib/api-services'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
import { socialInsuranceApi } from '../lib/api-services'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label } from '../components/ui/Input'
|
||||
import { MonthlyRow, MonthlyHousingRow } from './social-insurance/MonthlyRows'
|
||||
import SpecialDeductionTab from './social-insurance/SpecialDeductionTab'
|
||||
import CommercialInsuranceTab from './social-insurance/CommercialInsuranceTab'
|
||||
|
||||
// 金额格式化:保留两位小数 + 千分位
|
||||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
@@ -470,6 +473,10 @@ export default function SocialInsurance() {
|
||||
</Button>
|
||||
</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 ? (
|
||||
<>
|
||||
{(housingAllAccounts || []).length > 1 && (
|
||||
@@ -899,8 +906,20 @@ export default function SocialInsurance() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 基数说明(仅社保/公积金Tab显示) */}
|
||||
{(tab === 'social' || tab === 'housing') && (
|
||||
<p className="text-sm text-gray-400">
|
||||
社保/公积金基数按上年度月均工资核定,每人不同,在员工基本信息中设置。比例和基数上下限按版本管理,通常每年7月调整。
|
||||
发薪批次计算时按批次月份自动匹配对应版本配置。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ========== 月度办理 Tab ========== */}
|
||||
{tab === 'monthly' && (
|
||||
<div className="space-y-3">
|
||||
<PageGuide>
|
||||
月度办理用于按月获取社保增减员名单并完成办理。流程:①选择月份点击「获取」→ ②系统自动汇总新增、减员、正常缴费人员 → ③确认无误后点击「完成办理」保存记录。办理完成后自动更新员工社保状态。
|
||||
</PageGuide>
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-medium">月度办理</h2>
|
||||
@@ -1121,6 +1140,7 @@ export default function SocialInsurance() {
|
||||
)
|
||||
})()}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== 专项附加扣除 Tab ========== */}
|
||||
@@ -1133,752 +1153,7 @@ export default function SocialInsurance() {
|
||||
<CommercialInsuranceTab />
|
||||
)}
|
||||
|
||||
<p className="text-sm text-gray-400">
|
||||
社保/公积金基数按上年度月均工资核定,每人不同,在员工基本信息中设置。比例和基数上下限按版本管理,通常每年7月调整。
|
||||
发薪批次计算时按批次月份自动匹配对应版本配置。
|
||||
</p>
|
||||
</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 { 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 { Input, Select, Label } from '../components/ui/Input'
|
||||
import Button from '../components/ui/Button'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
import QueryError from '../components/ui/QueryError'
|
||||
|
||||
interface SpecialStatus {
|
||||
id: string
|
||||
@@ -105,6 +108,7 @@ export default function SpecialStatus() {
|
||||
const [typeFilter, setTypeFilter] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [fetchError, setFetchError] = useState<any>(null)
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<SpecialStatus | null>(null)
|
||||
@@ -135,6 +139,7 @@ export default function SpecialStatus() {
|
||||
|
||||
const fetchList = async () => {
|
||||
setLoading(true)
|
||||
setFetchError(null)
|
||||
try {
|
||||
const params: any = { page, pageSize }
|
||||
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
|
||||
setList(res.list)
|
||||
setTotal(res.total)
|
||||
} catch (err: any) {
|
||||
setFetchError(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -201,7 +208,7 @@ export default function SpecialStatus() {
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.employeeId) { alert('请选择员工'); return }
|
||||
if (!form.employeeId) { toast.error('请选择员工'); return }
|
||||
try {
|
||||
const data: any = { ...form }
|
||||
// 空字符串转 null
|
||||
@@ -220,7 +227,7 @@ export default function SpecialStatus() {
|
||||
fetchList()
|
||||
fetchStats()
|
||||
} 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()
|
||||
fetchStats()
|
||||
} 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 (
|
||||
<div className="space-y-6">
|
||||
<PageGuide>
|
||||
特殊状态台账用于跟踪三期(孕期/产期/哺乳期)、工伤、医疗期等特殊员工状态。系统自动计算状态剩余天数并发送到期提醒。可按类型、状态筛选查看,及时关注即将到期的特殊员工。
|
||||
</PageGuide>
|
||||
{/* 标题 */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">特殊状态台账</h1>
|
||||
@@ -312,6 +322,8 @@ export default function SpecialStatus() {
|
||||
{/* 列表 */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-400">加载中...</div>
|
||||
) : fetchError ? (
|
||||
<QueryError error={fetchError} onRetry={fetchList} />
|
||||
) : list.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<AlertTriangle className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { FileText, Copy, X, ChevronRight, Download, BookOpen, HelpCircle, Plus, Edit, Trash2, Building2 } from 'lucide-react'
|
||||
import { FileText, Copy, X, Download, BookOpen, HelpCircle, Plus, Edit, Trash2, Building2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { templatesApi } from '../lib/api-services'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
@@ -10,6 +10,7 @@ import { Input, Label, Select } from '../components/ui/Input'
|
||||
import Modal from '../components/ui/Modal'
|
||||
import EmptyState from '../components/ui/EmptyState'
|
||||
import Pagination from '../components/ui/Pagination'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
CONTRACT: '合同',
|
||||
@@ -300,6 +301,7 @@ function SystemTemplates() {
|
||||
|
||||
function EnterpriseTemplates() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [category, setCategory] = useState<string>('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
@@ -448,7 +450,7 @@ function EnterpriseTemplates() {
|
||||
<Edit className="w-3 h-3" />编辑
|
||||
</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"
|
||||
>
|
||||
<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 { Stepper } from '../components/ui/Stepper'
|
||||
import { InlineAlert } from '../components/ui/InlineAlert'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
import jsPDF from 'jspdf'
|
||||
import { rosterApi, terminationApi } from '../lib/api-services'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
@@ -12,6 +13,7 @@ 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'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
|
||||
// 金额格式化:保留两位小数 + 千分位
|
||||
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: '' },
|
||||
]
|
||||
|
||||
interface RosterEmployee {
|
||||
id: string
|
||||
name: string
|
||||
department: string
|
||||
status: string
|
||||
hasTermination?: boolean
|
||||
latestTerminationStatus?: string
|
||||
hireDate: string
|
||||
monthlySalary: number
|
||||
latestContract: any
|
||||
counts: any
|
||||
}
|
||||
|
||||
interface EmployeeProfile {
|
||||
id: string
|
||||
name: string
|
||||
@@ -171,6 +160,7 @@ interface EmployeeProfile {
|
||||
|
||||
export default function Termination() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [view, setView] = useState<'list' | 'wizard' | 'detail'>('list')
|
||||
const [draftId, setDraftId] = useState<string | null>(null)
|
||||
const [step, setStep] = useState(0)
|
||||
@@ -182,7 +172,7 @@ export default function Termination() {
|
||||
const [checklist, setChecklist] = useState<Record<string, boolean>>({})
|
||||
const [acknowledgeRisk, setAcknowledgeRisk] = useState(false)
|
||||
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 [handoverItems, setHandoverItems] = useState(DEFAULT_HANDOVER_ITEMS)
|
||||
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],
|
||||
queryFn: async () => {
|
||||
const params: any = { page: draftPage, pageSize: draftPageSize }
|
||||
@@ -524,28 +514,37 @@ export default function Termination() {
|
||||
}
|
||||
}, [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 = () => {
|
||||
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 === 2) return true
|
||||
// step 2: 合规检查 — required 项必须勾选
|
||||
if (step === 2) {
|
||||
if (!checklistItems) return true
|
||||
const requiredItems = checklistItems.filter(item => item.suggestionType === 'required')
|
||||
if (requiredItems.length === 0) return true
|
||||
return requiredItems.every(item => checklist[item.key] === true)
|
||||
}
|
||||
// step 3: 费用结算 — 有补偿金时需确认
|
||||
if (step === 3) return true
|
||||
if (step === 4) return true
|
||||
// step 4: 工作交接 — 关键项需完成
|
||||
if (step === 4) {
|
||||
const keyItems = handoverItems.filter(item => item.key === 'work_handover' || item.key === 'equipment_return' || item.key === 'access_revoke')
|
||||
if (keyItems.length === 0) return true
|
||||
return keyItems.every(item => item.done)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
saveMutation.mutate({
|
||||
employeeId,
|
||||
reason,
|
||||
terminationDate: new Date(terminationDate).toISOString(),
|
||||
socialInsEndMonth: socialInsEndMonth || terminationDate.slice(0, 7),
|
||||
housingFundEndMonth: housingFundEndMonth || terminationDate.slice(0, 7),
|
||||
compensation: costResult?.totalSeverance || 0,
|
||||
checklist,
|
||||
remark: '',
|
||||
})
|
||||
}
|
||||
|
||||
/** 保存草稿(任意步骤可调用) */
|
||||
const handleSaveDraft = () => {
|
||||
const breakdown = costResult ? {
|
||||
@@ -553,7 +552,8 @@ export default function Termination() {
|
||||
noticePay: costResult.noticePay,
|
||||
doublePay: costResult.doublePay,
|
||||
other: 0,
|
||||
total: costResult.grandTotal,
|
||||
total: adjustedTotal,
|
||||
systemTotal: costResult.grandTotal,
|
||||
adjustments: compAdjustments,
|
||||
} : null
|
||||
|
||||
@@ -564,7 +564,7 @@ export default function Termination() {
|
||||
terminationDate: terminationDate ? new Date(terminationDate).toISOString() : new Date().toISOString(),
|
||||
socialInsEndMonth: socialInsEndMonth || terminationDate.slice(0, 7),
|
||||
housingFundEndMonth: housingFundEndMonth || terminationDate.slice(0, 7),
|
||||
compensation: costResult?.grandTotal || 0,
|
||||
compensation: adjustedTotal || 0,
|
||||
checklist,
|
||||
currentStep: step,
|
||||
compensationBreakdown: breakdown,
|
||||
@@ -610,41 +610,6 @@ export default function Termination() {
|
||||
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)
|
||||
useEffect(() => {
|
||||
if (saveMutation.isSuccess && costResult && selectedEmployee) {
|
||||
@@ -731,6 +696,9 @@ export default function Termination() {
|
||||
{/* 草稿列表视图 */}
|
||||
{view === 'list' && (
|
||||
<>
|
||||
<PageGuide>
|
||||
解聘补偿页面用于规范处理离职、解聘审批及补偿核算。流程:①新建解聘草稿,选择员工和解聘原因 → ②系统自动计算经济补偿金 → ③合规检查(合同状态、医疗期、孕期等) → ④确认补偿方案 → ⑤生成解聘协议等法律文书。支持协商解除、过错解除、非过错解除、经济性裁员等场景。
|
||||
</PageGuide>
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
<Input
|
||||
placeholder="搜索员工姓名或部门"
|
||||
@@ -844,8 +812,8 @@ export default function Termination() {
|
||||
)}
|
||||
{item.status === 'DRAFT' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`确认执行「${item.employeeName}」的解聘手续?\n确认后员工状态将变更为离职,社保/公积金将停缴,此操作不可撤销。`)) {
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: '执行解聘', message: `确认执行「${item.employeeName}」的解聘手续?\n确认后员工状态将变更为离职,社保/公积金将停缴,此操作不可撤销。` })) {
|
||||
setDraftId(item.id)
|
||||
executeMutation.mutate()
|
||||
}
|
||||
@@ -1548,12 +1516,23 @@ export default function Termination() {
|
||||
<div>社保截止:{socialInsEndMonth || terminationDate.slice(0, 7)}</div>
|
||||
<div>公积金截止:{housingFundEndMonth || terminationDate.slice(0, 7)}</div>
|
||||
{costResult && (
|
||||
<div>补偿金合计:¥{fmt(costResult.grandTotal)}</div>
|
||||
<>
|
||||
{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>工作交接:{handoverItems.filter(i => i.done).length}/{handoverItems.length} 项完成</div>
|
||||
{compAdjustments.length > 0 && (
|
||||
<div className="text-amber-700">补偿金已手动调整 {compAdjustments.length} 项</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 风险提示 */}
|
||||
@@ -1771,9 +1750,17 @@ export default function Termination() {
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />上一步
|
||||
</Button>
|
||||
{step < 4 ? (
|
||||
<Button onClick={() => setStep(step + 1)} disabled={!canProceed()}>
|
||||
下一步<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
<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()}>
|
||||
下一步<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
) : step === 4 ? (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={handleSaveDraft} disabled={saveDraftMutation.isPending}>
|
||||
|
||||
@@ -6,12 +6,14 @@ import {
|
||||
Repeat, Pause, FileText, XCircle, UserX, FileMinus, Briefcase,
|
||||
Loader2, ChevronRight, Trash2, Send, X, Eye,
|
||||
} from 'lucide-react'
|
||||
import { workProcessApi } from '../lib/api-services'
|
||||
import { workProcessApi, templatesApi } 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'
|
||||
import Pagination from '../components/ui/Pagination'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
import QueryError from '../components/ui/QueryError'
|
||||
|
||||
const PROCESS_ICONS: Record<string, any> = {
|
||||
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: [
|
||||
{ key: 'name', 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' },
|
||||
],
|
||||
INCOME_CERT: [
|
||||
{ key: 'enterpriseTemplateId', label: '关联企业模板(选填)', type: 'enterprise-template' },
|
||||
{ key: 'employeeName', label: '员工姓名', type: 'text' },
|
||||
{ key: 'idCardNumber', 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' },
|
||||
],
|
||||
LEAVING_CERT: [
|
||||
{ key: 'enterpriseTemplateId', label: '关联企业模板(选填)', type: 'enterprise-template' },
|
||||
{ key: 'employeeName', label: '员工姓名', type: 'text' },
|
||||
{ key: 'idCardNumber', 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() {
|
||||
const queryClient = useQueryClient()
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
@@ -149,7 +159,7 @@ export default function WorkProcess() {
|
||||
const [page, setPage] = useState(1)
|
||||
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],
|
||||
queryFn: async () => {
|
||||
const params: any = { page, pageSize }
|
||||
@@ -240,6 +250,9 @@ export default function WorkProcess() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageGuide>
|
||||
用工办理用于管理员工入职、转正、调岗、离职等全生命周期手续。选择办理类型后填写相关信息并提交,系统自动生成对应文书并更新员工档案。支持批量办理和流程跟踪。
|
||||
</PageGuide>
|
||||
{/* 发起办理 */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -294,6 +307,8 @@ export default function WorkProcess() {
|
||||
|
||||
{isLoading ? (
|
||||
<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 ? (
|
||||
<div className="text-center py-8 text-sm text-gray-400">暂无办理记录</div>
|
||||
) : (
|
||||
@@ -371,6 +386,8 @@ export default function WorkProcess() {
|
||||
value={formData[field.key] || ''}
|
||||
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||
/>
|
||||
) : field.type === 'enterprise-template' ? (
|
||||
<EnterpriseTemplateSelect value={formData[field.key] || ''} onChange={(v) => handleFieldChange(field.key, v)} />
|
||||
) : (
|
||||
<Input
|
||||
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">
|
||||
{Object.entries(data.formData || {}).map(([key, value]: [string, any]) => (
|
||||
<div key={key} className="flex text-xs">
|
||||
<span className="text-gray-500 w-28 shrink-0">{key}</span>
|
||||
<span className="text-gray-900">{String(value)}</span>
|
||||
<span className="text-gray-500 w-28 shrink-0">{FIELD_LABEL_MAP[key] || key}</span>
|
||||
<span className="text-gray-900">{key === 'enterpriseTemplateId' && value ? `已关联企业模板` : (value ? String(value) : '-')}</span>
|
||||
</div>
|
||||
))}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
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 { 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 { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
@@ -67,27 +67,99 @@ export default function Login() {
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
<Logo className="w-8 h-8 text-primary" />
|
||||
<span className="text-xl font-bold">企业用工专家</span>
|
||||
</div>
|
||||
const features = [
|
||||
{ icon: Users, title: '智能花名册', desc: '员工档案全生命周期管理' },
|
||||
{ icon: FileText, title: '合同自动化', desc: '电子签约 + 到期预警' },
|
||||
{ icon: Shield, title: '合规风控', desc: 'AI 驱动的用工风险检测' },
|
||||
{ icon: Zap, title: '薪税一键算', desc: '工资社保个税自动计算' },
|
||||
]
|
||||
|
||||
<div className="card">
|
||||
<h1 className="text-lg font-semibold mb-4">登录</h1>
|
||||
return (
|
||||
<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">
|
||||
{/* 移动端 Logo */}
|
||||
<div className="flex lg:hidden items-center justify-center gap-2 mb-8">
|
||||
<Logo className="w-8 h-8 text-primary" />
|
||||
<span className="text-xl font-bold">企业用工专家</span>
|
||||
</div>
|
||||
|
||||
{/* 欢迎语 */}
|
||||
<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 && (
|
||||
<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>
|
||||
<Label>手机号</Label>
|
||||
<Input
|
||||
type="tel"
|
||||
placeholder="请输入手机号"
|
||||
className="h-11"
|
||||
{...register('phone')}
|
||||
maxLength={11}
|
||||
/>
|
||||
@@ -100,38 +172,61 @@ export default function Login() {
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="请输入密码"
|
||||
className="h-11 pr-10"
|
||||
{...register('password')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
</div>
|
||||
{errors.password && <p className="text-xs text-red-500 mt-1">{errors.password.message}</p>}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary focus:ring-offset-0"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">记住账号密码</span>
|
||||
</label>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary focus:ring-offset-0"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">记住账号密码</span>
|
||||
</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}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
<Button type="submit" size="lg" className="w-full h-11" disabled={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>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between text-sm">
|
||||
<Link to="/forgot-password" className="text-primary hover:underline">忘记密码?</Link>
|
||||
<Link to="/register" className="text-primary hover:underline">注册新企业</Link>
|
||||
{/* 分割线 */}
|
||||
<div className="mt-8 mb-6 flex items-center gap-4">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
TrendingDown, Calendar, ChevronRight, Filter,
|
||||
} from 'lucide-react'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
import { InlineAlert } from '../../components/ui/InlineAlert'
|
||||
import PageGuide from '../../components/ui/PageGuide'
|
||||
import QueryError from '../../components/ui/QueryError'
|
||||
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() {
|
||||
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'],
|
||||
queryFn: async () => {
|
||||
return await dashboardApi.risks()
|
||||
@@ -71,6 +72,9 @@ export default function RiskCenter() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageGuide>
|
||||
统一风险中心汇总展示合同到期、薪酬异常、社保漏缴、合规风险等各类人力资源风险。按风险等级(高/中/低)分类展示,支持筛选和快速跳转处理。建议定期查看以及时发现和处置风险。
|
||||
</PageGuide>
|
||||
{/* 页头 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -163,6 +167,8 @@ export default function RiskCenter() {
|
||||
<Card>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
) : isError ? (
|
||||
<QueryError error={error} onRetry={refetch} />
|
||||
) : filteredRisks.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">
|
||||
{risks.length === 0 ? '暂无风险项,一切正常' : '当前筛选条件下无匹配项'}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
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 { 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 { 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 { Input, Select, Label } from '../../components/ui/Input'
|
||||
import Button from '../../components/ui/Button'
|
||||
@@ -36,7 +37,6 @@ export default function PlatformOrgs() {
|
||||
})
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [editAdmin, setEditAdmin] = useState({ name: '', phone: '', password: '' })
|
||||
const [savingAdmin, setSavingAdmin] = useState(false)
|
||||
|
||||
const fetchOrgs = async () => {
|
||||
setLoading(true)
|
||||
@@ -94,13 +94,13 @@ export default function PlatformOrgs() {
|
||||
setEditOrg(null)
|
||||
fetchOrgs()
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.error?.message || '保存失败')
|
||||
toast.error(err.response?.data?.error?.message || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!createForm.name || !createForm.adminPhone || !createForm.adminPassword) {
|
||||
alert('企业名称、管理员手机号、密码不能为空')
|
||||
toast.error('企业名称、管理员手机号、密码不能为空')
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
@@ -114,7 +114,7 @@ export default function PlatformOrgs() {
|
||||
})
|
||||
fetchOrgs()
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.error?.message || '创建失败')
|
||||
toast.error(err.response?.data?.error?.message || '创建失败')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
@@ -127,7 +127,7 @@ export default function PlatformOrgs() {
|
||||
setDeleteOrg(null)
|
||||
fetchOrgs()
|
||||
} 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 { Search, Ban, CheckCircle } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { platformApi } from '../../lib/api-services'
|
||||
import { Input, Select } from '../../components/ui/Input'
|
||||
import Button from '../../components/ui/Button'
|
||||
@@ -59,7 +60,7 @@ export default function PlatformUsers() {
|
||||
await platformApi.toggleUser(user.id)
|
||||
fetchUsers()
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.error?.message || '操作失败')
|
||||
toast.error(err.response?.data?.error?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
* 员工 Hub 首页 — 员工端统一入口
|
||||
* 展示个人概览、待办事项、快捷入口、公司公告
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
DollarSign, FileText, CalendarCheck, ScrollText,
|
||||
TrendingUp, Clock, AlertCircle, ChevronRight,
|
||||
DollarSign, FileText, CalendarCheck, ScrollText, CalendarClock,
|
||||
AlertCircle, ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { portalApi } from '../../lib/api-services'
|
||||
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/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/leave', label: '休假申请', icon: CalendarClock, color: 'bg-cyan-50 text-cyan-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 { 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 Card from '../../components/ui/Card'
|
||||
import { InlineAlert } from '../../components/ui/InlineAlert'
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
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 Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useState, useRef } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { rosterApi, attachmentApi } from '../../lib/api-services'
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { attachmentApi } 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"
|
||||
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 { Select } from "../../components/ui/Input"
|
||||
import { Paperclip, Trash2, Eye, Download } from "lucide-react"
|
||||
|
||||
export default function AttachmentInfo({ employeeId, attachments }: { employeeId: string; attachments: any[] }) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useState, useRef } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { rosterApi, employeeApi } from '../../lib/api-services'
|
||||
import { useState } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { rosterApi } 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"
|
||||
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,14 +1,12 @@
|
||||
import { QRCodeSVG } from "qrcode.react"
|
||||
import { useUnsavedChanges } from "../../hooks/useUnsavedChanges"
|
||||
import { useState, useRef } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { rosterApi, attachmentApi, employeeApi } from '../../lib/api-services'
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { attachmentApi, 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"
|
||||
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 { AlertTriangle, Paperclip, Trash2, Eye, Download } from "lucide-react"
|
||||
import { fmt } from "./shared"
|
||||
|
||||
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>
|
||||
)}
|
||||
<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>
|
||||
|
||||
{/* 特殊状态 */}
|
||||
|
||||
@@ -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 Button from "../../components/ui/Button"
|
||||
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 { UserX, DollarSign, Building2 } from "lucide-react"
|
||||
import { fmt, terminateReasonMap } from "./shared"
|
||||
import { CityHistoryTab } from "./PayslipSocialInfo"
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useState, useRef } from "react"
|
||||
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 { useConfirm } from '../../hooks/useConfirm'
|
||||
import Card from "../../components/ui/Card"
|
||||
import Button from "../../components/ui/Button"
|
||||
import { Input, Label, Select } from "../../components/ui/Input"
|
||||
import Modal from "../../components/ui/Modal"
|
||||
import { 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 { FileText, AlertTriangle, X, Paperclip, Trash2, Info, Download } from "lucide-react"
|
||||
|
||||
export default function ContractInfo({ employeeId, contracts, hireDate }: { employeeId: string; contracts: any[]; hireDate: string }) {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
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 contractFileRef = useRef<HTMLInputElement>(null)
|
||||
@@ -281,7 +281,7 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
|
||||
)}
|
||||
</div>
|
||||
<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"
|
||||
title="删除合同"
|
||||
>
|
||||
@@ -308,7 +308,6 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
|
||||
const mime = previewUrl.startsWith('data:') ? previewUrl.match(/data:(.*?);/)?.[1] || '' : ''
|
||||
const isImage = mime.startsWith('image/')
|
||||
const isPdf = mime === 'application/pdf'
|
||||
const previewable = isImage || isPdf
|
||||
|
||||
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) }}>
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { useState, useRef } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { rosterApi } 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"
|
||||
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 { AlertTriangle, Check } from "lucide-react"
|
||||
|
||||
// ========== 违纪记录管理 ==========
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* 可被 Roster、Termination、SocialInsurance 等页面复用
|
||||
*/
|
||||
import { useState, ReactNode } from 'react'
|
||||
import { X, Phone, Mail, MapPin, Calendar, Briefcase, DollarSign, FileText, AlertTriangle } from 'lucide-react'
|
||||
import { fmt, DetailTab, TAB_GROUPS, TAB_COUNT_KEYS } from './shared'
|
||||
import { X, Phone, Mail, MapPin, Calendar, Briefcase, DollarSign, AlertTriangle } from 'lucide-react'
|
||||
import { fmt, DetailTab, TAB_GROUPS } from './shared'
|
||||
|
||||
interface EmployeeSummary {
|
||||
id: string
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { useState, useRef } from "react"
|
||||
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 { useAuthStore } from "../../store/authStore"
|
||||
import Card from "../../components/ui/Card"
|
||||
import Button from "../../components/ui/Button"
|
||||
import { Input, Label, Select } from "../../components/ui/Input"
|
||||
import Modal from "../../components/ui/Modal"
|
||||
import { 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 { AlertTriangle, Scale } from "lucide-react"
|
||||
|
||||
// ========== 仲裁证据链 ==========
|
||||
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import { useState, useRef } from "react"
|
||||
import { useState } from "react"
|
||||
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 Card from "../../components/ui/Card"
|
||||
import Button from "../../components/ui/Button"
|
||||
import { Input, Label, Select } from "../../components/ui/Input"
|
||||
import Modal from "../../components/ui/Modal"
|
||||
import { 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 PayslipSocialInfo({ payslips, monthlyProcessRecords }: { payslips: any[]; socialInsRecords: any[]; housingFundRecords: any[]; monthlyProcessRecords: any[] }) {
|
||||
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 (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-1">
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { useState, useRef } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { rosterApi } 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"
|
||||
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 { AlertTriangle, Check } from "lucide-react"
|
||||
|
||||
// ========== 绩效记录管理 ==========
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user