diff --git a/backend/src/routes/import.routes.ts b/backend/src/routes/import.routes.ts index d9918ec..757d497 100644 --- a/backend/src/routes/import.routes.ts +++ b/backend/src/routes/import.routes.ts @@ -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, '工伤')) === '是', diff --git a/backend/src/routes/roster.routes.ts b/backend/src/routes/roster.routes.ts index 5043c23..7d7ec90 100644 --- a/backend/src/routes/roster.routes.ts +++ b/backend/src/routes/roster.routes.ts @@ -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 diff --git a/backend/src/routes/social.routes.ts b/backend/src/routes/social.routes.ts index a8cb813..6b67923 100644 --- a/backend/src/routes/social.routes.ts +++ b/backend/src/routes/social.routes.ts @@ -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, diff --git a/backend/src/services/contract.service.ts b/backend/src/services/contract.service.ts index d14102f..327ee63 100644 --- a/backend/src/services/contract.service.ts +++ b/backend/src/services/contract.service.ts @@ -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 = { @@ -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 diff --git a/docs/20260730-优化-1.md b/docs/20260730-优化-1.md new file mode 100644 index 0000000..b50554c --- /dev/null +++ b/docs/20260730-优化-1.md @@ -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 | 人工成本模板 | ✅ 功能缺失 | diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a5456bc..9b5936d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 }) { setSidebarOpen(true)} />
- }>{children} + + }>{children} +
@@ -103,7 +106,9 @@ function PlatformLayout({ children }: { children: React.ReactNode }) {
- }>{children} + + }>{children} +
@@ -116,14 +121,18 @@ function PortalLayoutWrapper({ children, showNav = true }: { children: React.Rea return (
- }>{children} + + }>{children} +
) } return ( - }>{children} + + }>{children} + ) } diff --git a/frontend/src/components/ui/ErrorBoundary.tsx b/frontend/src/components/ui/ErrorBoundary.tsx new file mode 100644 index 0000000..dd11ba2 --- /dev/null +++ b/frontend/src/components/ui/ErrorBoundary.tsx @@ -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 { + 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 ( +
+
+ + + +
+

页面加载出错

+

+ {this.state.error?.message || '页面渲染过程中发生异常,请尝试刷新或返回重试'} +

+
+ + +
+
+ ) + } + + return this.props.children + } +} diff --git a/frontend/src/pages/Attendance.tsx b/frontend/src/pages/Attendance.tsx index 44bcd99..7278e20 100644 --- a/frontend/src/pages/Attendance.tsx +++ b/frontend/src/pages/Attendance.tsx @@ -468,6 +468,7 @@ function ScheduleTab() { const [showAssign, setShowAssign] = useState(false) const [selectedShiftId, setSelectedShiftId] = useState('') const [selectedEmployeeIds, setSelectedEmployeeIds] = useState>(new Set()) + const [searchQuery, setSearchQuery] = useState('') const { data: shifts } = useQuery({ queryKey: ['shifts'], @@ -602,8 +603,19 @@ function ScheduleTab() {
+ 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" + />
- {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) => (
{form.signMethod === 'PAPER' && (
- +