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:
selfrelease
2026-07-30 18:34:36 +08:00
parent 411adb7ddc
commit 55286819ae
11 changed files with 480 additions and 39 deletions
+2 -2
View File
@@ -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, '工伤')) === '是',
+2
View File
@@ -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
+4
View File
@@ -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,
+17 -2
View File
@@ -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
+215
View File
@@ -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
View File
@@ -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
}
}
+13 -1
View File
@@ -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>
+51 -14
View File
@@ -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 = () => {
+90 -12
View File
@@ -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>
+2 -4
View File
@@ -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>