优化: 大文件拆分+代码分割+按需加载+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:
freedakgmail
2026-08-04 07:53:37 +08:00
parent 1da385cd5d
commit 2968484d2d
109 changed files with 8950 additions and 5926 deletions
+134
View File
@@ -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 | 新功能开发,需单独规划 | 待规划 |
+243
View File
@@ -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",
+2
View File
@@ -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");
+53
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -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}`)
})
+9 -7
View File
@@ -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)
+4 -2
View File
@@ -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 }, '以下环境变量使用了默认值,仅限开发环境')
}
}
+23
View File
@@ -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
+8
View File
@@ -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 }
}
+2 -1
View File
@@ -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 -1
View File
@@ -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
-1
View File
@@ -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 -1
View File
@@ -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()
-1
View File
@@ -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
+1 -2
View File
@@ -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' })
+198
View File
@@ -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
+7 -6
View File
@@ -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)
+39 -1
View File
@@ -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)
}
+8 -30
View File
@@ -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) {
+60
View File
@@ -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
+7 -5
View File
@@ -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()
+13 -24
View File
@@ -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') {
+13 -12
View File
@@ -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)
+34
View File
@@ -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(),
})
+42
View File
@@ -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(),
})
+28 -1
View File
@@ -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,
}
+2 -11
View File
@@ -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,
+21 -6
View File
@@ -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) => `收入证明
+652 -117
View File
@@ -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. ...&#10;2. ...&#10;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");
+4
View File
@@ -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="验收测试清单"
-59
View File
@@ -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
}
}
+2 -2
View File
@@ -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 -1
View File
@@ -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' },
+2 -2
View 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 = () => {
+37
View File
@@ -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>
)
}
+36
View File
@@ -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
View File
@@ -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 (
+97 -68
View File
@@ -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
View File
@@ -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)
+1 -1
View File
@@ -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'
File diff suppressed because it is too large Load Diff
+20 -2
View File
@@ -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 -1
View File
@@ -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'
+1 -1
View File
@@ -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 -1
View File
@@ -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'
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -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="系统操作将自动生成证据链" />
) : (
+259
View File
@@ -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>
)
}
File diff suppressed because it is too large Load Diff
-1
View File
@@ -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,
+8 -1
View File
@@ -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="点击右上角新建制度" />
) : (
+10 -3
View File
@@ -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>
) : (
+8 -1
View File
@@ -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>
) : (
+32 -2
View File
@@ -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'] })
}
+24 -749
View File
@@ -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>
)
}
+15 -3
View File
@@ -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" />
+4 -2
View File
@@ -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" />
+63 -76
View File
@@ -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}>
+46 -5
View File
@@ -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>
)
}
+263
View File
@@ -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>
)
}
+401
View File
@@ -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>
)
}
+24
View File
@@ -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: '其他' },
]
+123 -28
View File
@@ -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>
+9 -3
View File
@@ -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
+454
View File
@@ -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>
)
}
+237
View File
@@ -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>
)
}
+286
View File
@@ -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>
)
}
+6 -6
View File
@@ -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 || '操作失败')
}
}
+3 -3
View File
@@ -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' },
]
+227
View File
@@ -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'
+4 -6
View File
@@ -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"
/** 考勤/加班/培训合并组件 */
+9 -5
View File
@@ -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">/7portal端填报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"
+5 -6
View File
@@ -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 @@
* RosterTerminationSocialInsurance
*/
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
+2 -6
View File
@@ -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