fix: HR系统优化批次1 - P0/P1问题修复
P0-3: 修复合同状态判断逻辑,有合同记录但signDate为null时不再误判未签 P0-5: 修复参保城市默认北京问题,导入和预览均改为null P0-11: 添加全局ErrorBoundary防止白屏,三处布局均包裹 P1-2: 合同附件改为可选,允许先保存再补充上传 P1-7.2: 排班弹窗增加员工搜索(姓名/部门) P1-8.2: 加班费导入支持Excel(xlsx/xls)格式,兼容中英文列名 P1-9: 社保/公积金基数月度办理支持逐人修改,后端返回recordId
This commit is contained in:
@@ -124,7 +124,7 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
|
||||
const rows = XLSX.utils.sheet_to_json(empSheet)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), department: val(getField(r, '部门')) || '未分配', hireDate: getField(r, '入职日期'), salary: num(getField(r, '月工资')), phone: val(getField(r, '手机号')), idCard: val(getField(r, '身份证号')), city: val(getField(r, '参保城市')) || '北京', status: 'normal', errors: [] as string[], warnings: [] as string[] }
|
||||
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), department: val(getField(r, '部门')) || '未分配', hireDate: getField(r, '入职日期'), salary: num(getField(r, '月工资')), phone: val(getField(r, '手机号')), idCard: val(getField(r, '身份证号')), city: val(getField(r, '参保城市')) || null, status: 'normal', errors: [] as string[], warnings: [] as string[] }
|
||||
if (!row.name) { row.status = 'error'; row.errors.push('姓名为空') }
|
||||
const hireDate = parseDate(getField(r, '入职日期'))
|
||||
if (!hireDate) { row.status = 'error'; row.errors.push('入职日期格式错误') }
|
||||
@@ -285,7 +285,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
|
||||
socialInsBase: num(getField(r, '社保基数')) || num(salary),
|
||||
housingFundBase: num(getField(r, '公积金基数')) || num(salary),
|
||||
specialDeduction: num(getField(r, '专项附加扣除')) || 0,
|
||||
city: val(getField(r, '参保城市')) || '北京',
|
||||
city: val(getField(r, '参保城市')) || null,
|
||||
isPregnant: val(getField(r, '孕期')) === '是',
|
||||
isInMedicalPeriod: val(getField(r, '医疗期')) === '是',
|
||||
isWorkInjured: val(getField(r, '工伤')) === '是',
|
||||
|
||||
@@ -119,6 +119,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
endDate: latestContract.endDate,
|
||||
contractType: latestContract.contractType,
|
||||
hireDate: e.hireDate,
|
||||
hasRecord: true,
|
||||
})
|
||||
: getContractStatus({
|
||||
signDate: null,
|
||||
@@ -126,6 +127,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
endDate: null,
|
||||
contractType: 'UNSIGNED',
|
||||
hireDate: e.hireDate,
|
||||
hasRecord: false,
|
||||
})
|
||||
const isResigned = e.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today)
|
||||
const isPreHire = !isResigned && e.hireDate > todayEnd
|
||||
|
||||
@@ -878,6 +878,7 @@ router.get('/monthly-changes', async (req: AuthRequest, res: Response, next: Nex
|
||||
const config = await getConfigForCity(r.city)
|
||||
const detail = config ? calcSocialDetail(r.base, config) : null
|
||||
return {
|
||||
recordId: r.id,
|
||||
employeeId: r.employeeId,
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
@@ -948,6 +949,7 @@ router.get('/housing/monthly-changes', async (req: AuthRequest, res: Response, n
|
||||
const config = await getConfigForCity(r.city)
|
||||
const detail = config ? calcHousingDetail(r.base, config) : null
|
||||
return {
|
||||
recordId: r.id,
|
||||
employeeId: r.employeeId,
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
@@ -1012,6 +1014,7 @@ router.get('/active-declaration', async (req: AuthRequest, res: Response, next:
|
||||
const config = await getConfigForCity(r.city)
|
||||
const detail = config ? calcSocialDetail(r.base, config) : null
|
||||
return {
|
||||
recordId: r.id,
|
||||
employeeId: r.employeeId,
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
@@ -1073,6 +1076,7 @@ router.get('/housing/active-declaration', async (req: AuthRequest, res: Response
|
||||
const config = await getConfigForCity(r.city)
|
||||
const detail = config ? calcHousingDetail(r.base, config) : null
|
||||
return {
|
||||
recordId: r.id,
|
||||
employeeId: r.employeeId,
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
|
||||
@@ -24,6 +24,8 @@ export function getContractStatus(contract: {
|
||||
endDate: Date | null
|
||||
contractType: string
|
||||
hireDate: Date
|
||||
/** hasRecord: 是否存在合同记录(区分"有合同但未填签订日期"和"完全无合同") */
|
||||
hasRecord?: boolean
|
||||
}): { status: string; statusText: string; riskLevel: 'high' | 'medium' | 'low' | 'safe' } {
|
||||
const today = new Date()
|
||||
const typeLabelMap: Record<string, string> = {
|
||||
@@ -35,7 +37,11 @@ export function getContractStatus(contract: {
|
||||
}
|
||||
const typeLabel = typeLabelMap[contract.contractType] || ''
|
||||
|
||||
if (!contract.signDate || contract.contractType === 'UNSIGNED') {
|
||||
// 只有真正没有合同记录(hasRecord=false)或类型为 UNSIGNED 时,才判定为"未签合同"
|
||||
// 有合同记录但 signDate 为 null 时,不再判定为"未签合同"
|
||||
const isUnsigned = contract.contractType === 'UNSIGNED' || (contract.hasRecord === false && !contract.signDate)
|
||||
|
||||
if (isUnsigned) {
|
||||
const days = daysBetween(today, contract.hireDate)
|
||||
if (days > 365) {
|
||||
return { status: 'unsigned_over_year', statusText: '未签合同(已视为无固定期限)', riskLevel: 'high' }
|
||||
@@ -45,6 +51,7 @@ export function getContractStatus(contract: {
|
||||
return { status: 'unsigned', statusText: `未签合同(${days}天)`, riskLevel: 'medium' }
|
||||
}
|
||||
|
||||
// 有合同记录(FIXED/UNFIXED/LABOR/INTERNSHIP),即使 signDate 为 null 也按正常合同处理
|
||||
if (contract.endDate) {
|
||||
const daysToExpire = daysBetween(contract.endDate, today)
|
||||
if (daysToExpire < 0) {
|
||||
@@ -55,7 +62,13 @@ export function getContractStatus(contract: {
|
||||
return { status: 'active', statusText: `${typeLabel}·正常`, riskLevel: 'safe' }
|
||||
}
|
||||
|
||||
return { status: 'unfixed', statusText: '无固定期限·正常', riskLevel: 'safe' }
|
||||
// 无固定期限或有合同但无结束日期
|
||||
if (contract.contractType === 'UNFIXED') {
|
||||
return { status: 'unfixed', statusText: '无固定期限·正常', riskLevel: 'safe' }
|
||||
}
|
||||
|
||||
// 有合同记录但未填结束日期(如 FIXED 但 endDate 为 null),视为正常
|
||||
return { status: 'active', statusText: `${typeLabel}·正常`, riskLevel: 'safe' }
|
||||
}
|
||||
|
||||
export function validateProbation(contractMonths: number, probationMonths: number): { valid: boolean; max: number; message?: string } {
|
||||
@@ -112,6 +125,7 @@ export async function getEmployees(orgId: string, params: { page?: number; pageS
|
||||
endDate: latestContract.endDate,
|
||||
contractType: latestContract.contractType,
|
||||
hireDate: emp.hireDate,
|
||||
hasRecord: true,
|
||||
})
|
||||
: getContractStatus({
|
||||
signDate: null,
|
||||
@@ -119,6 +133,7 @@ export async function getEmployees(orgId: string, params: { page?: number; pageS
|
||||
endDate: null,
|
||||
contractType: 'UNSIGNED',
|
||||
hireDate: emp.hireDate,
|
||||
hasRecord: false,
|
||||
})
|
||||
|
||||
let decryptedSalary = 0
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
# 20260730 优化需求清单 - 1
|
||||
|
||||
> 来源:用户测试反馈
|
||||
> 日期:2026-07-30
|
||||
> 代码验证:已完成逐条核查
|
||||
|
||||
---
|
||||
|
||||
## 1. 批量导入:员工信息分步导入后无法补充导入合同
|
||||
|
||||
**问题**:员工导入模板支持多 Sheet,如果第一次只导入了员工基本信息(未填写合同 Sheet),后续无法再通过批量导入补充合同信息,只能逐条手录。
|
||||
|
||||
**代码验证**:⚠️ 部分确认
|
||||
- 后端 `import.routes.ts` 合同 Sheet 导入依赖 `empByHash`/`empByName` 匹配已有员工,**合同 Sheet 可以单独导入**(只要员工已存在)
|
||||
- 但员工 Sheet 导入时 `prisma.employee.create` 没有去重逻辑,第二次导入员工会报唯一约束错误
|
||||
- **实际问题**:用户不知道可以只上传含合同 Sheet 的 Excel 来补充合同,需要 UI 引导或分开的导入入口
|
||||
|
||||
**期望**:支持对已有员工进行合同信息的批量补充导入(后端已支持,前端需增加独立入口或引导)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 手动录入劳动合同:未上传附件前无法保存
|
||||
|
||||
**问题**:手动录入劳动合同时,在上传附件之前没有保存按钮,退出后再进入需要重新录入所有合同基本信息。
|
||||
|
||||
**代码验证**:✅ 确认真实问题
|
||||
- `ContractInfo.tsx:211-214`:保存按钮 disabled 条件包含 `form.signMethod === 'PAPER' && form.attachments.length === 0`
|
||||
- 纸质签署必须先上传附件才能保存,没有草稿/暂存机制
|
||||
|
||||
**期望**:允许先保存合同基本信息(草稿状态),附件后续再上传。
|
||||
|
||||
---
|
||||
|
||||
## 3. 合同录入后状态异常 + 附件无法预览/下载
|
||||
|
||||
**问题**:
|
||||
- 合同手工录入保存成功后,员工仍显示"未签订劳动合同"
|
||||
- 批量导入合同成功后,已签订合同的附件不能预览也不能下载
|
||||
|
||||
**代码验证**:✅ 确认真实问题(状态异常)+ ❌ 附件功能正常
|
||||
- `contract.service.ts:38`:`if (!contract.signDate || contract.contractType === 'UNSIGNED')` — 如果 `signDate` 为 null,直接判定为"未签合同"
|
||||
- 手工录入合同时签订日期非必填,未填则 `signDate` 为 null → 状态显示"未签订"
|
||||
- **根因**:`getContractStatus` 判断逻辑应以合同记录是否存在为准,而非 `signDate`
|
||||
- 附件预览/下载:`ContractInfo.tsx:296-347` 已实现完整的预览弹窗(支持 PDF/图片预览 + 下载),功能正常
|
||||
|
||||
**期望**:
|
||||
- 修复 `getContractStatus`:有合同记录且非 UNSIGNED 类型时,即使 `signDate` 为 null 也应显示"已签订"
|
||||
- 附件功能已正常,无需修改
|
||||
|
||||
---
|
||||
|
||||
## 4. 绩效考核:签字流程不明确
|
||||
|
||||
**问题**:
|
||||
- 被考核人签字入口不明确,不知道在哪里签字
|
||||
- 考核结果通过什么形式发给员工并签字未定义
|
||||
- 员工对考核结果有异议时,是通过系统提出还是直接跟考核人沟通?
|
||||
|
||||
**代码验证**:✅ 确认真实问题(功能缺失)
|
||||
- 当前绩效模块只有 HR 端录入功能,没有员工端签字/确认流程
|
||||
- 员工 Portal 中无绩效考核相关页面
|
||||
|
||||
**期望**:
|
||||
- 明确签字入口和流程(如员工端 Portal 推送 + 签字确认)
|
||||
- 提供异议申诉通道(系统内提交异议 → 考核人/HR 复核)
|
||||
|
||||
---
|
||||
|
||||
## 5. 批量导入:参保地区导入后统一变成北京
|
||||
|
||||
**问题**:批量导入员工信息时,参保地区不管填写哪个城市,导入后都变成"北京",需要二次修改。
|
||||
|
||||
**代码验证**:✅ 确认真实问题
|
||||
- `import.routes.ts:288`:`city: val(getField(r, '参保城市')) || '北京'`
|
||||
- 如果 Excel 中参保城市列名为空或列名不匹配,`getField` 返回空 → 默认"北京"
|
||||
- **根因**:`getField` 匹配列名可能不精确,或用户填写了列但列名与代码中的 `'参保城市'` 不完全一致
|
||||
|
||||
**期望**:导入时正确识别参保城市列,空值时不用默认"北京",改为 null 或提示用户。
|
||||
|
||||
---
|
||||
|
||||
## 6. 员工离职:离职原因细分的目的和输出不明确
|
||||
|
||||
**问题**:离职原因细分为"个人原因/职业发展/薪资原因"等,不确定细分目的。是否能导出不同离职原因占比?
|
||||
|
||||
**代码验证**:✅ 确认真实问题(缺少分析报表)
|
||||
- 离职原因分类已存在,但没有统计分析报表功能
|
||||
- 无离职原因占比导出功能
|
||||
|
||||
**期望**:
|
||||
- 明确离职原因分类体系
|
||||
- 提供离职原因统计分析报表(占比、趋势、部门对比)
|
||||
|
||||
---
|
||||
|
||||
## 7. 考勤确认:多个子问题
|
||||
|
||||
### 7.1 批量导入不显示上传状态 + 服务器内部错误
|
||||
**代码验证**:⚠️ 部分确认
|
||||
- 前端 `Attendance.tsx:315-338`:有导入弹窗和文件选择,但上传后只显示 `toast.success` 或 `toast.error`,不显示详细导入结果
|
||||
- 后端 `import.routes.ts:439-468`:考勤记录导入逻辑正常,但可能因 Sheet 名不匹配(需为"考勤记录")导致无数据导入
|
||||
- "服务器内部错误"可能是其他 Sheet 数据导入失败导致整体 500
|
||||
|
||||
### 7.2 排班:不能搜索,只能下拉勾选
|
||||
**代码验证**:✅ 确认真实问题
|
||||
- `Attendance.tsx:592-620`:排班弹窗中员工列表无搜索框,只有滚动勾选
|
||||
|
||||
### 7.3 每日出勤:无导入按钮
|
||||
**代码验证**:✅ 确认真实问题
|
||||
- `Attendance.tsx:626-695`:DailyTab 只有日期选择和表格展示,无导入按钮
|
||||
- 但考勤确认 Tab 有导入功能,每日出勤数据来源于考勤记录导入
|
||||
|
||||
### 7.4 休假记录:能否接入企业微信请假记录
|
||||
**代码验证**:✅ 确认只能手动录入
|
||||
- `Attendance.tsx:793+`:休假记录只有手动新增,无导入功能,无企业微信对接
|
||||
|
||||
---
|
||||
|
||||
## 8. 薪酬管理:多个子问题
|
||||
|
||||
### 8.1 薪酬模板:可编辑项手工录入不能保存
|
||||
**代码验证**:❌ 无法确认
|
||||
- 模板管理 `TemplateManager` 有完整 CRUD(新增/编辑/删除),`isEditable` 字段可设置
|
||||
- 发薪批次详情 `BatchDetail` 中 `editableFields` 包含 `baseSalary/overtimePay/allowance/deduction/bonus` 等,支持点击单元格编辑
|
||||
- **可能原因**:用户指的是发薪批次中某些自定义模板项无法编辑,或编辑后保存接口报错(需实际测试确认)
|
||||
|
||||
### 8.2 加班费计算:导入考勤数据不能导入
|
||||
**代码验证**:✅ 确认真实问题
|
||||
- `Money.tsx:1527-1555`:加班费导入只支持 **CSV 格式**(逗号分隔),不支持 Excel
|
||||
- 按姓名匹配员工,如果姓名不匹配则跳过
|
||||
- 用户可能上传了 Excel 文件导致解析失败
|
||||
|
||||
### 8.3 工资条税率试算:加班费计算方式不明确
|
||||
**代码验证**:✅ 确认真实问题
|
||||
- 加班费按统一规则计算(工作日 1.5 倍 / 休息日 2 倍 / 法定节假日 3 倍),不支持按员工个人配置工资构成
|
||||
|
||||
---
|
||||
|
||||
## 9. 社保公积金:无法按个人修改基数
|
||||
|
||||
**代码验证**:⚠️ 部分确认
|
||||
- 后端 `social.routes.ts:1185` 有 `PUT /records/social/:id/correct` 接口支持修正单条社保记录
|
||||
- 后端 `social.routes.ts:244` 有 `POST /config/:id/adjust-apply` 支持批量调基
|
||||
- **但前端 `SocialInsurance.tsx` 中未找到调用 correct 接口的 UI 入口**,只有批量调基预览/应用
|
||||
- 前端缺少逐人修改基数的操作入口
|
||||
|
||||
**期望**:前端增加逐人修改社保/公积金基数的 UI 入口。
|
||||
|
||||
---
|
||||
|
||||
## 10. 考勤机数据导入后自动计算加班工资
|
||||
|
||||
**代码验证**:✅ 确认功能缺失
|
||||
- 当前考勤导入只创建 `AttendanceRecord`,不自动计算加班费
|
||||
- 加班费需要单独在薪酬模块录入或 CSV 导入
|
||||
|
||||
---
|
||||
|
||||
## 11. 系统不稳定:新模块常出现"系统出错了"闪退
|
||||
|
||||
**代码验证**:✅ 确认真实问题
|
||||
- 前端无 `ErrorBoundary` 组件,模块加载失败时直接白屏
|
||||
- 无全局错误处理和降级 UI
|
||||
|
||||
**期望**:添加全局 `ErrorBoundary`,模块加载失败时显示降级页面而非白屏。
|
||||
|
||||
---
|
||||
|
||||
## 12. 社保城市:可选城市较少,不能录入新城市
|
||||
|
||||
**代码验证**:✅ 确认真实问题
|
||||
- `import.routes.ts:54-60`:社保基数校验只支持 5 个城市(北京/上海/广州/深圳/杭州)
|
||||
- 社保配置 `social.routes.ts` 支持按城市配置,但城市列表来源于已配置数据,无预设全量城市
|
||||
- 前端无自定义录入新城市的入口
|
||||
|
||||
---
|
||||
|
||||
## 13. 发薪时考勤数据自动生成奖惩类数据
|
||||
|
||||
**代码验证**:✅ 确认功能缺失
|
||||
- 考勤导入和薪酬模块之间无自动联动
|
||||
- 迟到/早退/缺勤等考勤异常不会自动生成薪酬扣款明细
|
||||
|
||||
---
|
||||
|
||||
## 14. 新增人工成本模板
|
||||
|
||||
**代码验证**:✅ 确认功能缺失
|
||||
- 系统中无人工成本模板功能
|
||||
|
||||
---
|
||||
|
||||
## 优先级建议(已根据验证结果调整)
|
||||
|
||||
| 优先级 | 编号 | 问题 | 验证结果 |
|
||||
|--------|------|------|----------|
|
||||
| P0-紧急 | 3 | 合同状态异常(signDate 判断逻辑) | ✅ 确认,根因已定位 |
|
||||
| P0-紧急 | 11 | 系统无 ErrorBoundary 闪退 | ✅ 确认 |
|
||||
| P0-紧急 | 5 | 参保地区默认"北京" | ✅ 确认,代码行已定位 |
|
||||
| P1-高 | 2 | 合同录入必须先传附件 | ✅ 确认 |
|
||||
| P1-高 | 9 | 社保基数前端无逐人修改入口 | ⚠️ 后端已有接口,前端缺 UI |
|
||||
| P1-高 | 7.2 | 排班不支持搜索 | ✅ 确认 |
|
||||
| P1-高 | 8.2 | 加班费只支持 CSV 不支持 Excel | ✅ 确认 |
|
||||
| P2-中 | 1 | 分步导入(后端已支持,前端缺引导) | ⚠️ 需前端优化 |
|
||||
| P2-中 | 7.1 | 考勤导入错误提示不清晰 | ⚠️ 需改善错误反馈 |
|
||||
| P2-中 | 4 | 绩效签字流程缺失 | ✅ 确认 |
|
||||
| P2-中 | 6 | 离职原因分析报表缺失 | ✅ 确认 |
|
||||
| P2-中 | 12 | 社保城市列表少 | ✅ 确认 |
|
||||
| P2-中 | 13 | 考勤自动生成奖惩数据 | ✅ 功能缺失 |
|
||||
| P3-低 | 7.3 | 每日出勤无导入(考勤Tab已有) | ✅ 确认 |
|
||||
| P3-低 | 7.4 | 休假无导入/企业微信对接 | ✅ 确认 |
|
||||
| P3-低 | 8.1 | 薪酬模板可编辑项 | ❌ 需实际测试确认 |
|
||||
| P3-低 | 8.3 | 加班费按统一规则计算 | ✅ 确认 |
|
||||
| P3-低 | 10 | 考勤自动计算加班工资 | ✅ 功能缺失 |
|
||||
| P3-低 | 14 | 人工成本模板 | ✅ 功能缺失 |
|
||||
+13
-4
@@ -8,6 +8,7 @@ import MobileTabBar from './components/layout/MobileTabBar'
|
||||
import PortalLayout from './components/layout/PortalLayout'
|
||||
import PageContainer from './components/layout/PageContainer'
|
||||
import { SkeletonPage } from './components/ui/Skeleton'
|
||||
import ErrorBoundary from './components/ui/ErrorBoundary'
|
||||
|
||||
const Login = lazy(() => import('./pages/auth/Login'))
|
||||
const Register = lazy(() => import('./pages/auth/Register'))
|
||||
@@ -68,7 +69,9 @@ function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
<TopNav onMenuClick={() => setSidebarOpen(true)} />
|
||||
<main className="flex-1 py-6 pb-20 md:pb-6">
|
||||
<PageContainer>
|
||||
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
|
||||
</ErrorBoundary>
|
||||
</PageContainer>
|
||||
</main>
|
||||
<MobileTabBar />
|
||||
@@ -103,7 +106,9 @@ function PlatformLayout({ children }: { children: React.ReactNode }) {
|
||||
</header>
|
||||
<main className="flex-1 py-6 px-4 md:px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -116,14 +121,18 @@ function PortalLayoutWrapper({ children, showNav = true }: { children: React.Rea
|
||||
return (
|
||||
<div className="min-h-screen bg-surface pt-safe pb-safe">
|
||||
<main className="max-w-md mx-auto py-6 px-4">
|
||||
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<PortalLayout>
|
||||
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
|
||||
</ErrorBoundary>
|
||||
</PortalLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Component, ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局错误边界组件
|
||||
* 捕获子组件渲染异常,显示降级 UI 而非白屏
|
||||
*/
|
||||
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: React.ErrorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, errorInfo)
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] p-6 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-red-50 flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-base font-semibold text-gray-900 mb-1">页面加载出错</h2>
|
||||
<p className="text-sm text-gray-500 mb-4 max-w-md">
|
||||
{this.state.error?.message || '页面渲染过程中发生异常,请尝试刷新或返回重试'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
<button
|
||||
onClick={this.handleReload}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
刷新页面
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -468,6 +468,7 @@ function ScheduleTab() {
|
||||
const [showAssign, setShowAssign] = useState(false)
|
||||
const [selectedShiftId, setSelectedShiftId] = useState('')
|
||||
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<Set<string>>(new Set())
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const { data: shifts } = useQuery<any>({
|
||||
queryKey: ['shifts'],
|
||||
@@ -602,8 +603,19 @@ function ScheduleTab() {
|
||||
</div>
|
||||
<div>
|
||||
<Label>选择员工({selectedEmployeeIds.size} 人已选)</Label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索员工姓名或部门..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="w-full px-3 py-2 mb-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<div className="max-h-60 overflow-y-auto border rounded-lg divide-y">
|
||||
{employees.map((emp: any) => (
|
||||
{employees.filter((emp: any) => {
|
||||
if (!searchQuery.trim()) return true
|
||||
const q = searchQuery.trim().toLowerCase()
|
||||
return emp.name?.toLowerCase().includes(q) || emp.department?.toLowerCase().includes(q)
|
||||
}).map((emp: any) => (
|
||||
<label key={emp.employeeId} className="flex items-center gap-2 px-3 py-2 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={selectedEmployeeIds.has(emp.employeeId)} onChange={() => toggleEmployee(emp.employeeId)} />
|
||||
<span className="text-sm">{emp.name}</span>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
import * as XLSX from 'xlsx'
|
||||
import { Calculator, AlertCircle, Info, Check, Upload, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText, X, ChevronLeft, Wallet, LayoutTemplate, Clock, Receipt, Users, TrendingDown, TrendingUp, BadgeCheck } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
@@ -1527,34 +1528,70 @@ function OvertimeCalculator() {
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const text = event.target?.result as string
|
||||
const lines = text.split('\n').filter(l => l.trim())
|
||||
const empList = employees?.items || []
|
||||
const fileName = file.name.toLowerCase()
|
||||
|
||||
const parseRows = (rows: any[]): void => {
|
||||
const items: any[] = []
|
||||
const empList = employees?.items || []
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const cols = lines[i].split(',').map(c => c.trim())
|
||||
const empName = cols[0]
|
||||
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: cols[4] || month,
|
||||
weekdayHours: Number(cols[1]) || 0,
|
||||
weekendHours: Number(cols[2]) || 0,
|
||||
holidayHours: Number(cols[3]) || 0,
|
||||
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('未匹配到员工,请确保CSV第一列为员工姓名')
|
||||
toast.error('未匹配到员工,请确保文件包含"姓名"列')
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
|
||||
if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls')) {
|
||||
// Excel 格式解析
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const data = new Uint8Array(event.target?.result as ArrayBuffer)
|
||||
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 = () => {
|
||||
|
||||
@@ -1048,9 +1048,9 @@ export default function SocialInsurance() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{add.map((i: any) => <MonthlyRow key={`sa-${city}-${i.employeeId}`} item={i} type="add" />)}
|
||||
{sub.map((i: any) => <MonthlyRow key={`ss-${city}-${i.employeeId}`} item={i} type="sub" />)}
|
||||
{normal.map((i: any) => <MonthlyRow key={`sn-${city}-${i.employeeId}`} item={i} type="normal" />)}
|
||||
{add.map((i: any) => <MonthlyRow key={`sa-${city}-${i.employeeId}`} item={i} type="add" onCorrected={handleMonthlyProcess} />)}
|
||||
{sub.map((i: any) => <MonthlyRow key={`ss-${city}-${i.employeeId}`} item={i} type="sub" onCorrected={handleMonthlyProcess} />)}
|
||||
{normal.map((i: any) => <MonthlyRow key={`sn-${city}-${i.employeeId}`} item={i} type="normal" onCorrected={handleMonthlyProcess} />)}
|
||||
</tbody>
|
||||
{(add.length > 0 || normal.length > 0) && (
|
||||
<tfoot>
|
||||
@@ -1090,9 +1090,9 @@ export default function SocialInsurance() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{add.map((i: any) => <MonthlyHousingRow key={`ha-${city}-${i.employeeId}`} item={i} type="add" />)}
|
||||
{sub.map((i: any) => <MonthlyHousingRow key={`hs-${city}-${i.employeeId}`} item={i} type="sub" />)}
|
||||
{normal.map((i: any) => <MonthlyHousingRow key={`hn-${city}-${i.employeeId}`} item={i} type="normal" />)}
|
||||
{add.map((i: any) => <MonthlyHousingRow key={`ha-${city}-${i.employeeId}`} item={i} type="add" onCorrected={handleMonthlyProcess} />)}
|
||||
{sub.map((i: any) => <MonthlyHousingRow key={`hs-${city}-${i.employeeId}`} item={i} type="sub" onCorrected={handleMonthlyProcess} />)}
|
||||
{normal.map((i: any) => <MonthlyHousingRow key={`hn-${city}-${i.employeeId}`} item={i} type="normal" onCorrected={handleMonthlyProcess} />)}
|
||||
</tbody>
|
||||
{(add.length > 0 || normal.length > 0) && (
|
||||
<tfoot>
|
||||
@@ -1146,19 +1146,58 @@ export default function SocialInsurance() {
|
||||
)
|
||||
}
|
||||
|
||||
/** 月度办理社保行组件(可展开查看各险种明细) */
|
||||
function MonthlyRow({ item: i, type }: { item: any; type: 'add' | 'sub' | 'normal' }) {
|
||||
/** 月度办理社保行组件(可展开查看各险种明细,支持修改基数) */
|
||||
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 }) => api.put(`/social/records/social/${i.recordId}/correct`, 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">¥{fmt(i.base)}</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>
|
||||
@@ -1196,17 +1235,56 @@ function MonthlyRow({ item: i, type }: { item: any; type: 'add' | 'sub' | 'norma
|
||||
)
|
||||
}
|
||||
|
||||
/** 月度办理公积金行组件 */
|
||||
function MonthlyHousingRow({ item: i, type }: { item: any; type: 'add' | 'sub' | 'normal' }) {
|
||||
/** 月度办理公积金行组件(支持修改基数) */
|
||||
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 }) => api.put(`/social/records/housing/${i.recordId}/correct`, 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">¥{fmt(i.base)}</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>
|
||||
|
||||
@@ -167,7 +167,7 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
|
||||
</div>
|
||||
{form.signMethod === 'PAPER' && (
|
||||
<div className="md:col-span-2">
|
||||
<Label>合同附件 *</Label>
|
||||
<Label>合同附件(可选,保存后可再补充上传)</Label>
|
||||
<input ref={contractFileRef} type="file" multiple className="hidden" onChange={handleContractFileUpload} />
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => contractFileRef.current?.click()}>
|
||||
@@ -209,9 +209,7 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
|
||||
}
|
||||
addContractMutation.mutate(payload)
|
||||
}} disabled={
|
||||
addContractMutation.isPending || !form.startDate ||
|
||||
(form.signMethod === 'PAPER' && form.attachments.length === 0) ||
|
||||
(form.signMethod === 'ELECTRONIC' && (!form.electronicContractNo || !form.electronicContractUrl))
|
||||
addContractMutation.isPending || !form.startDate
|
||||
}>
|
||||
{addContractMutation.isPending ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user