From a2e9ba55c26a2330cfedbc8630bfb911a1fffb7f Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Sun, 9 Aug 2026 11:59:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=2020260809=20=E7=B3=BB=E7=BB=9F=E4=BC=98?= =?UTF-8?q?=E5=8C=96=20-=20=E5=85=A8=E9=83=A828=E9=A1=B9=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D(P0=C3=976+P1=C3=9716+P2=C3=976)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0: 福利批量参保/离职证明下载防乱码/考勤模板合并Sheet/补卡修改/附件在线查看删除 P1: 分页pageSize修复/离职导出筛选/撤回删除草稿/加班费自动计算/考勤加班汇总/证据链异常详情/制度催办/模板导入Word/社保封顶保底/校验字段提示/职务字段/社保费用明细/弹窗防误关/身份证查重/证明员工下拉/培训批量 P2: 离职流程去重/社保基数覆盖输入/薪税入口改名/添加员工引导/绩效模板清理 --- backend/prisma/schema.prisma | 20 + backend/src/routes/employee.routes.ts | 19 + .../src/routes/enterprise-template.routes.ts | 11 +- backend/src/routes/export.routes.ts | 7 + backend/src/routes/import.routes.ts | 201 +++- backend/src/routes/payroll.routes.ts | 82 ++ backend/src/routes/policy.routes.ts | 57 +- backend/src/routes/roster.routes.ts | 180 +++- backend/src/routes/template.routes.ts | 10 +- backend/src/routes/termination.routes.ts | 20 + backend/src/schemas/contract.schema.ts | 2 + backend/src/services/attendance.service.ts | 13 +- backend/src/services/contract.service.ts | 55 +- backend/src/services/evidence.service.ts | 15 +- backend/src/services/work-process.service.ts | 46 +- docs/20260809-优化.md | 867 ++++++++++++++++++ frontend/package-lock.json | 131 +++ frontend/package.json | 1 + frontend/src/components/ui/Modal.tsx | 5 +- frontend/src/components/ui/Pagination.tsx | 3 +- frontend/src/lib/api-services.ts | 35 +- frontend/src/lib/errorToast.ts | 19 + frontend/src/pages/Attendance.tsx | 30 +- frontend/src/pages/Contracts.tsx | 16 +- frontend/src/pages/EmployeeBenefits.tsx | 14 +- frontend/src/pages/Evidence.tsx | 13 + frontend/src/pages/Money.tsx | 7 +- frontend/src/pages/Policies.tsx | 36 +- frontend/src/pages/Roster.tsx | 35 +- frontend/src/pages/Templates.tsx | 31 +- frontend/src/pages/Termination.tsx | 126 ++- frontend/src/pages/WorkProcess.tsx | 118 ++- frontend/src/pages/money/OvertimeTab.tsx | 88 +- frontend/src/pages/money/PayslipTab.tsx | 8 +- frontend/src/pages/roster/BasicInfo.tsx | 51 +- frontend/src/pages/roster/ContractInfo.tsx | 115 ++- frontend/src/pages/roster/EmployeeProfile.tsx | 2 +- frontend/src/pages/roster/EvidenceChain.tsx | 71 +- .../src/pages/roster/PayslipSocialInfo.tsx | 39 +- frontend/src/pages/roster/PerformanceInfo.tsx | 89 +- .../src/pages/roster/PerformanceRecords.tsx | 321 ++++++- frontend/src/pages/roster/TrainingRecords.tsx | 132 ++- frontend/src/pages/roster/modals.tsx | 96 +- 43 files changed, 2913 insertions(+), 324 deletions(-) create mode 100644 docs/20260809-优化.md create mode 100644 frontend/src/lib/errorToast.ts diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index cbee6d1..8e3f5da 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -170,6 +170,7 @@ model Organization { attendanceRecords AttendanceRecord[] trainingRecords TrainingRecord[] performanceRecords PerformanceRecord[] + performanceTemplates PerformanceTemplate[] retirementPolicies RetirementPolicy[] socialMonthlyProcesses SocialMonthlyProcess[] evidenceChains EvidenceChain[] @@ -644,11 +645,14 @@ model PerformanceRecord { employeeId String employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) period String // 考核周期 YYYY-MM 或 YYYY-Q1 + periodType String @default("MONTHLY") // MONTHLY/QUARTERLY/YEARLY score Float @default(0) // 考核得分 grade String @default("B") // A/B/C/D result String @default("QUALIFIED") // EXCELLENT/QUALIFIED/NEED_IMPROVE/UNQUALIFIED summary String? // 考核评语 improvementPlan String? // 改进计划(不胜任时) + templateId String? // 关联绩效模板(选填) + dimensionScores Json? // 各维度得分明细 { dimensionName: score } employeeAck Boolean @default(false) ackDate DateTime? reviewer String? @@ -659,6 +663,22 @@ model PerformanceRecord { @@index([orgId, employeeId]) } +model PerformanceTemplate { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + name String // 模板名称 + description String? // 模板说明 + dimensions Json // 考核维度 [{ name, weight, maxScore, description }] + gradeRules Json? // 等级规则 [{ grade, result, minScore, maxScore }] + isDefault Boolean @default(false) + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([orgId]) +} + // ========== 员工端表 ========== model Payslip { diff --git a/backend/src/routes/employee.routes.ts b/backend/src/routes/employee.routes.ts index 1d3614a..10ffcd4 100644 --- a/backend/src/routes/employee.routes.ts +++ b/backend/src/routes/employee.routes.ts @@ -3,6 +3,7 @@ import { authMiddleware, AuthRequest } from '../middleware/auth' import { auditLog } from '../middleware/auditLog' import { createEvidence } from '../services/evidence.service' import prisma from '../lib/prisma' +import { sha256 } from '../lib/crypto' import { createEmployeeSchema, updateEmployeeSchema, @@ -97,6 +98,24 @@ router.get('/:id', authMiddleware, async (req: AuthRequest, res, next) => { } }) +// 身份证查重 +router.get('/check-id-card', authMiddleware, async (req: AuthRequest, res, next) => { + try { + const idCard = req.query.idCard as string + if (!idCard || idCard.length < 18) { + return res.json({ success: true, data: { exists: false } }) + } + const hash = sha256(idCard) + const employee = await prisma.employee.findFirst({ + where: { orgId: req.user!.orgId, idCardHash: hash }, + select: { id: true, name: true, department: true, status: true }, + }) + res.json({ success: true, data: { exists: !!employee, employee } }) + } catch (err) { + next(err) + } +}) + router.post('/', authMiddleware, async (req: AuthRequest, res, next) => { try { const data = createEmployeeSchema.parse(req.body) diff --git a/backend/src/routes/enterprise-template.routes.ts b/backend/src/routes/enterprise-template.routes.ts index db33cc2..b0d683f 100644 --- a/backend/src/routes/enterprise-template.routes.ts +++ b/backend/src/routes/enterprise-template.routes.ts @@ -146,10 +146,17 @@ router.get('/:id/download', authMiddleware, async (req: AuthRequest, res: Respon if (!template) { return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } }) } + // 包装为 HTML 格式以确保 Word 正确打开 + const htmlContent = ` +${template.name} + +${template.content}` const encoded = encodeURIComponent(template.name + '.doc') - res.setHeader('Content-Type', 'application/msword') + res.setHeader('Content-Type', 'application/msword; charset=utf-8') res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`) - res.send(template.content) + res.send(htmlContent) } catch (err) { next(err) } diff --git a/backend/src/routes/export.routes.ts b/backend/src/routes/export.routes.ts index b72d64d..ef51e13 100644 --- a/backend/src/routes/export.routes.ts +++ b/backend/src/routes/export.routes.ts @@ -359,9 +359,16 @@ router.get('/terminations', authMiddleware, async (req: AuthRequest, res: Respon const status = req.query.status as string | undefined const department = req.query.department as string | undefined const search = req.query.search as string | undefined + const dateFrom = req.query.dateFrom as string | undefined + const dateTo = req.query.dateTo as string | undefined const where: any = { orgId } if (status) where.status = status + if (dateFrom || dateTo) { + where.terminationDate = {} + if (dateFrom) where.terminationDate.gte = new Date(dateFrom) + if (dateTo) where.terminationDate.lte = new Date(dateTo + 'T23:59:59') + } if (department || search) { where.employee = {} if (department) where.employee.department = department diff --git a/backend/src/routes/import.routes.ts b/backend/src/routes/import.routes.ts index 3b06057..d94a44f 100644 --- a/backend/src/routes/import.routes.ts +++ b/backend/src/routes/import.routes.ts @@ -541,12 +541,22 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy } const wb = XLSX.read(req.file.buffer, { type: 'buffer', cellDates: true }) - const result: any = { month, attendance: 0, overtime: 0, salaryChanges: 0, socialInsChanges: 0, housingFundChanges: 0, errors: [] as string[], strategies: { '考勤记录': '覆盖(同员工同日覆盖)', '加班记录': '累加(同员工同月累加)', '薪资调整': '覆盖(关闭旧记录,新建新记录)', '社保变动': '覆盖(关闭旧记录,新建新记录)', '公积金变动': '覆盖(关闭旧记录,新建新记录)' } } + const result: any = { month, attendance: 0, overtime: 0, discipline: 0, salaryChanges: 0, socialInsChanges: 0, housingFundChanges: 0, errors: [] as string[], strategies: { '考勤记录': '覆盖(同员工同日覆盖)', '加班记录': '累加(同员工同月累加)', '违纪记录': '追加(同员工同日可多条)', '薪资调整': '覆盖(关闭旧记录,新建新记录)', '社保变动': '覆盖(关闭旧记录,新建新记录)', '公积金变动': '覆盖(关闭旧记录,新建新记录)' } } const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, monthlySalary: true, department: true, idCardHash: true } }) const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e])) const empByName = new Map(employees.map(e => [e.name, e])) + // 获取加班费配置,用于自动计算 totalPay + const otConfig = await prisma.overtimeConfig.findUnique({ where: { orgId } }) ?? { weekdayRate: 1.5, weekendRate: 2.0, holidayRate: 3.0, monthlyDays: 21.75, dailyHours: 8 } + function calcOvertimePay(monthlyWage: number, wdHours: number, weHours: number, hoHours: number) { + const hourlyWage = (monthlyWage || 0) / otConfig.monthlyDays / otConfig.dailyHours + const weekdayPay = hourlyWage * otConfig.weekdayRate * wdHours + const weekendPay = hourlyWage * otConfig.weekendRate * weHours + const holidayPay = hourlyWage * otConfig.holidayRate * hoHours + return Math.round((weekdayPay + weekendPay + holidayPay) * 100) / 100 + } + function findEmp(r: any) { const idCard = val(getField(r, '身份证号')) if (idCard) { @@ -556,56 +566,109 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy return empByName.get(val(getField(r, '姓名'))) } - // 考勤记录 + // 考勤记录 + 加班记录(支持合并Sheet"考勤与加班"或独立Sheet) + const mergedSheet = wb.Sheets['考勤与加班'] const attSheet = wb.Sheets['考勤记录'] - if (attSheet) { - const rows = XLSX.utils.sheet_to_json(attSheet) - for (let i = 0; i < rows.length; i++) { - const r = rows[i] as any - try { - const emp = findEmp(r) - if (!emp) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue } - const date = parseDate(getField(r, '日期')) - if (!date) { result.errors.push(`考勤第${i + 2}行:日期格式错误`); continue } - const statusMap: any = { '正常': 'NORMAL', '迟到': 'LATE', '早退': 'EARLY_LEAVE', '缺勤': 'ABSENT', '请假': 'LEAVE', '出差': 'BUSINESS_TRIP' } - await prisma.attendanceRecord.upsert({ - where: { employeeId_date: { employeeId: emp.id, date } }, - create: { orgId, employeeId: emp.id, date, status: statusMap[val(getField(r, '考勤状态'))] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null, createdBy: userId }, - update: { status: statusMap[val(getField(r, '考勤状态'))] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null }, - }) - result.attendance++ - } catch (e: any) { result.errors.push(`考勤第${i + 2}行:${e?.message || '导入失败'}`) } - } - } - - // 加班记录 const otSheet = wb.Sheets['加班记录'] - if (otSheet) { - const rows = XLSX.utils.sheet_to_json(otSheet) + const statusMap: any = { '正常': 'NORMAL', '迟到': 'LATE', '早退': 'EARLY_LEAVE', '缺勤': 'ABSENT', '请假': 'LEAVE', '出差': 'BUSINESS_TRIP' } + + if (mergedSheet) { + // 合并Sheet:每行同时处理考勤和加班 + const rows = XLSX.utils.sheet_to_json(mergedSheet) for (let i = 0; i < rows.length; i++) { const r = rows[i] as any try { const emp = findEmp(r) - if (!emp) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue } + if (!emp) { result.errors.push(`第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue } const date = parseDate(getField(r, '日期')) - if (!date) { result.errors.push(`加班第${i + 2}行:日期格式错误`); continue } - const otMonth = dateToMonth(date) - const hours = num(getField(r, '加班时长')) - const otType = val(getField(r, '加班类型')) || '工作日加班' - const wdHours = num(getField(r, '工作日加班时长')) || (otType.includes('工作日') ? hours : 0) - const weHours = num(getField(r, '休息日加班时长')) || (otType.includes('休息日') ? hours : 0) - const hoHours = num(getField(r, '法定节假日加班时长')) || (otType.includes('法定') ? hours : 0) - await prisma.overtimeRecord.upsert({ - where: { employeeId_month: { employeeId: emp.id, month: otMonth } }, - create: { orgId, employeeId: emp.id, month: otMonth, weekdayHours: wdHours, weekendHours: weHours, holidayHours: hoHours } as any, - update: { - weekdayHours: { increment: wdHours }, - weekendHours: { increment: weHours }, - holidayHours: { increment: hoHours }, - }, - }) - result.overtime++ - } catch (e: any) { result.errors.push(`加班第${i + 2}行:${e?.message || '导入失败'}`) } + if (!date) { result.errors.push(`第${i + 2}行:日期格式错误`); continue } + + // 考勤部分 + const attStatus = val(getField(r, '考勤状态')) + if (attStatus || val(getField(r, '上班时间')) || val(getField(r, '下班时间'))) { + await prisma.attendanceRecord.upsert({ + where: { employeeId_date: { employeeId: emp.id, date } }, + create: { orgId, employeeId: emp.id, date, status: statusMap[attStatus] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null, createdBy: userId }, + update: { status: statusMap[attStatus] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null }, + }) + result.attendance++ + } + + // 加班部分 + const wdHours = num(getField(r, '工作日加班时长')) + const weHours = num(getField(r, '休息日加班时长')) + const hoHours = num(getField(r, '法定节假日加班时长')) + if (wdHours > 0 || weHours > 0 || hoHours > 0) { + const otMonth = dateToMonth(date) + let monthlyWage = 0 + try { monthlyWage = Number(decrypt(emp.monthlySalary)) || 0 } catch { monthlyWage = Number(emp.monthlySalary) || 0 } + const totalPay = calcOvertimePay(monthlyWage, wdHours, weHours, hoHours) + await prisma.overtimeRecord.upsert({ + where: { employeeId_month: { employeeId: emp.id, month: otMonth } }, + create: { orgId, employeeId: emp.id, month: otMonth, weekdayHours: wdHours, weekendHours: weHours, holidayHours: hoHours, totalPay } as any, + update: { + weekdayHours: { increment: wdHours }, + weekendHours: { increment: weHours }, + holidayHours: { increment: hoHours }, + totalPay: { increment: totalPay }, + }, + }) + result.overtime++ + } + } catch (e: any) { result.errors.push(`第${i + 2}行:${e?.message || '导入失败'}`) } + } + } else { + // 向后兼容:独立Sheet + if (attSheet) { + const rows = XLSX.utils.sheet_to_json(attSheet) + for (let i = 0; i < rows.length; i++) { + const r = rows[i] as any + try { + const emp = findEmp(r) + if (!emp) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue } + const date = parseDate(getField(r, '日期')) + if (!date) { result.errors.push(`考勤第${i + 2}行:日期格式错误`); continue } + await prisma.attendanceRecord.upsert({ + where: { employeeId_date: { employeeId: emp.id, date } }, + create: { orgId, employeeId: emp.id, date, status: statusMap[val(getField(r, '考勤状态'))] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null, createdBy: userId }, + update: { status: statusMap[val(getField(r, '考勤状态'))] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null }, + }) + result.attendance++ + } catch (e: any) { result.errors.push(`考勤第${i + 2}行:${e?.message || '导入失败'}`) } + } + } + + if (otSheet) { + const rows = XLSX.utils.sheet_to_json(otSheet) + for (let i = 0; i < rows.length; i++) { + const r = rows[i] as any + try { + const emp = findEmp(r) + if (!emp) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue } + const date = parseDate(getField(r, '日期')) + if (!date) { result.errors.push(`加班第${i + 2}行:日期格式错误`); continue } + const otMonth = dateToMonth(date) + const hours = num(getField(r, '加班时长')) + const otType = val(getField(r, '加班类型')) || '工作日加班' + const wdHours = num(getField(r, '工作日加班时长')) || (otType.includes('工作日') ? hours : 0) + const weHours = num(getField(r, '休息日加班时长')) || (otType.includes('休息日') ? hours : 0) + const hoHours = num(getField(r, '法定节假日加班时长')) || (otType.includes('法定') ? hours : 0) + let monthlyWage = 0 + try { monthlyWage = Number(decrypt(emp.monthlySalary)) || 0 } catch { monthlyWage = Number(emp.monthlySalary) || 0 } + const totalPay = calcOvertimePay(monthlyWage, wdHours, weHours, hoHours) + await prisma.overtimeRecord.upsert({ + where: { employeeId_month: { employeeId: emp.id, month: otMonth } }, + create: { orgId, employeeId: emp.id, month: otMonth, weekdayHours: wdHours, weekendHours: weHours, holidayHours: hoHours, totalPay } as any, + update: { + weekdayHours: { increment: wdHours }, + weekendHours: { increment: weHours }, + holidayHours: { increment: hoHours }, + totalPay: { increment: totalPay }, + }, + }) + result.overtime++ + } catch (e: any) { result.errors.push(`加班第${i + 2}行:${e?.message || '导入失败'}`) } + } } } @@ -685,6 +748,27 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy } } + // 违纪记录 + const discSheet = wb.Sheets['违纪记录'] + if (discSheet) { + const rows = XLSX.utils.sheet_to_json(discSheet) + for (let i = 0; i < rows.length; i++) { + const r = rows[i] as any + try { + const emp = findEmp(r) + if (!emp) { result.errors.push(`违纪第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue } + const date = parseDate(getField(r, '日期')) + if (!date) { result.errors.push(`违纪第${i + 2}行:日期格式错误`); continue } + const typeMap: any = { '迟到': 'LATE', '旷工': 'ABSENT', '不服从': 'INSUBORDINATION', '违纪': 'MISCONDUCT', '违规': 'VIOLATE_POLICY', '其他': 'OTHER' } + const actMap: any = { '口头警告': 'ORAL_WARNING', '书面警告': 'WRITTEN_WARNING', '扣款': 'DEDUCTION', '降级': 'DEMOTION', '辞退': 'TERMINATION' } + await prisma.disciplinaryRecord.create({ + data: { orgId, employeeId: emp.id, violationDate: date, violationType: typeMap[val(getField(r, '违纪类型'))] || 'OTHER', description: val(getField(r, '描述')) || '', action: actMap[val(getField(r, '处罚'))] || 'ORAL_WARNING', createdBy: userId }, + }) + result.discipline++ + } catch (e: any) { result.errors.push(`违纪第${i + 2}行:${e?.message || '导入失败'}`) } + } + } + res.json({ success: true, data: result }) } catch (err) { next(err) @@ -694,11 +778,25 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy router.get('/monthly-template', authMiddleware, async (_req: AuthRequest, res: Response) => { const wb = XLSX.utils.book_new() - const attData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-01', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' }] - XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(attData), '考勤记录') - - const otData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班时长': 2, '加班类型': '工作日加班' }] - XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录') + // 合并考勤+加班为一个Sheet,减少重复录入姓名身份证号 + const attOtData = [{ + '姓名': '张三', + '身份证号': '110101199001011234', + '日期': '2024-06-01', + '考勤状态': '正常', + '上班时间': '09:00', + '下班时间': '18:00', + '工作日加班时长': 0, + '休息日加班时长': 0, + '法定节假日加班时长': 0, + '备注': '', + }] + const attOtWs = XLSX.utils.json_to_sheet(attOtData) + attOtWs['!cols'] = [ + { wch: 10 }, { wch: 20 }, { wch: 12 }, { wch: 10 }, { wch: 8 }, { wch: 8 }, + { wch: 14 }, { wch: 14 }, { wch: 16 }, { wch: 12 }, + ] + XLSX.utils.book_append_sheet(wb, attOtWs, '考勤与加班') const salaryData = [{ '姓名': '张三', '身份证号': '110101199001011234', '调整后月薪': 12000, '生效日期': '2024-06-01', '调薪原因': '年度调薪' }] XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(salaryData), '薪资调整') @@ -709,9 +807,12 @@ router.get('/monthly-template', authMiddleware, async (_req: AuthRequest, res: R const hfData = [{ '姓名': '张三', '身份证号': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }] XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(hfData), '公积金变动') + const discData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' }] + XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(discData), '违纪记录') + const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') - res.setHeader('Content-Disposition', contentDisposition('月度增减员导入模板.xlsx')) + res.setHeader('Content-Disposition', contentDisposition('考勤月度导入模板.xlsx')) res.send(buf) }) diff --git a/backend/src/routes/payroll.routes.ts b/backend/src/routes/payroll.routes.ts index cdeebc1..2a9e85b 100644 --- a/backend/src/routes/payroll.routes.ts +++ b/backend/src/routes/payroll.routes.ts @@ -129,6 +129,88 @@ router.put('/overtime/:id', async (req: AuthRequest, res: Response, next: NextFu } }) +// 从考勤记录同步加班工时 +router.post('/overtime/sync-from-attendance', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const { month } = req.body as { month: string } + if (!month || !/^\d{4}-\d{2}$/.test(month)) { + return res.status(400).json({ success: false, message: '请提供有效的月份(YYYY-MM)' }) + } + + const monthStart = new Date(month + '-01') + const monthEnd = new Date(monthStart) + monthEnd.setMonth(monthEnd.getMonth() + 1) + + // 获取该月所有考勤记录(含加班工时) + const records = await prisma.attendanceRecord.findMany({ + where: { orgId, date: { gte: monthStart, lt: monthEnd }, overtimeHours: { gt: 0 } }, + }) + + if (records.length === 0) { + return res.json({ success: false, message: '该月考勤记录中无加班工时' }) + } + + // 按员工汇总加班工时,按日期类型分类 + const empMap = new Map() + for (const r of records) { + const day = new Date(r.date) + const dayOfWeek = day.getDay() // 0=周日, 6=周六 + let type: 'weekday' | 'weekend' | 'holiday' = 'weekday' + if (dayOfWeek === 0 || dayOfWeek === 6) { + type = 'weekend' + } + // 简单判断法定节假日:这里使用周末判断,实际法定节假日需要额外配置 + // 如果有 holidayHours 字段在 attendanceRecord 中,优先使用 + + if (!empMap.has(r.employeeId)) { + empMap.set(r.employeeId, { weekday: 0, weekend: 0, holiday: 0 }) + } + const entry = empMap.get(r.employeeId)! + entry[type] += r.overtimeHours || 0 + } + + // 获取员工月工资用于计算加班费 + let config = await prisma.overtimeConfig.findUnique({ where: { orgId } }) + if (!config) config = await prisma.overtimeConfig.create({ data: { orgId } }) + + let synced = 0 + for (const [employeeId, hours] of empMap) { + const emp = await prisma.employee.findFirst({ where: { id: employeeId }, select: { monthlySalary: true } }) + let monthlyWage = 0 + try { monthlyWage = emp?.monthlySalary ? Number(decrypt(emp.monthlySalary)) : 0 } catch { monthlyWage = Number(emp?.monthlySalary) || 0 } + + const hourlyWage = monthlyWage / config.monthlyDays / config.dailyHours + const weekdayPay = hourlyWage * config.weekdayRate * hours.weekday + const weekendPay = hourlyWage * config.weekendRate * hours.weekend + const holidayPay = hourlyWage * config.holidayRate * hours.holiday + const totalPay = weekdayPay + weekendPay + holidayPay + + await prisma.overtimeRecord.upsert({ + where: { employeeId_month: { employeeId, month } }, + update: { + weekdayHours: hours.weekday, + weekendHours: hours.weekend, + holidayHours: hours.holiday, + weekdayPay, weekendPay, holidayPay, totalPay, + }, + create: { + orgId, employeeId, month, + weekdayHours: hours.weekday, + weekendHours: hours.weekend, + holidayHours: hours.holiday, + weekdayPay, weekendPay, holidayPay, totalPay, + }, + }) + synced++ + } + + res.json({ success: true, data: { synced, totalEmployees: empMap.size } }) + } catch (err) { + next(err) + } +}) + // ========== 工资条管理 ========== const payslipSchema = z.object({ diff --git a/backend/src/routes/policy.routes.ts b/backend/src/routes/policy.routes.ts index 59b83c3..4f5bcfc 100644 --- a/backend/src/routes/policy.routes.ts +++ b/backend/src/routes/policy.routes.ts @@ -1,5 +1,6 @@ import { Router, Response, NextFunction } from 'express' import { authMiddleware, AuthRequest } from '../middleware/auth' +import { requireAdmin } from '../middleware/rbac' import { z } from 'zod' import prisma from '../lib/prisma' import { @@ -90,24 +91,26 @@ router.delete('/:id', authMiddleware, async (req: AuthRequest, res: Response, ne router.get('/:id/read-stats', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { try { const orgId = req.user!.orgId - const policy = await prisma.policyDocument.findFirst({ where: { id: req.params.id, orgId }, select: { id: true } }) + const policy = await prisma.policyDocument.findFirst({ where: { id: req.params.id, orgId }, select: { id: true, title: true } }) if (!policy) { return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '制度不存在' } }) } - const [totalEmployees, readRecords] = await Promise.all([ - prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }), + const [allEmployees, readRecords] = await Promise.all([ + prisma.employee.findMany({ where: { orgId, status: 'ACTIVE' }, select: { id: true, name: true, department: true }, orderBy: { name: 'asc' } }), prisma.policyReadRecord.findMany({ where: { policyId: req.params.id, orgId }, include: { employee: { select: { id: true, name: true, department: true } } }, orderBy: { readAt: 'desc' }, }), ]) + const readEmpIds = new Set(readRecords.map(r => r.employeeId)) + const unreadEmployees = allEmployees.filter(e => !readEmpIds.has(e.id)) res.json({ success: true, data: { - total: totalEmployees, + total: allEmployees.length, readCount: readRecords.length, - unreadCount: totalEmployees - readRecords.length, + unreadCount: unreadEmployees.length, records: readRecords.map(r => ({ employeeId: r.employeeId, employeeName: r.employee.name, @@ -115,6 +118,11 @@ router.get('/:id/read-stats', authMiddleware, async (req: AuthRequest, res: Resp readAt: r.readAt.toISOString(), ip: r.ip, })), + unreadEmployees: unreadEmployees.map(e => ({ + employeeId: e.id, + employeeName: e.name, + department: e.department, + })), }, }) } catch (err) { @@ -122,4 +130,43 @@ router.get('/:id/read-stats', authMiddleware, async (req: AuthRequest, res: Resp } }) +/** 催办未签收员工 */ +router.post('/:id/remind', authMiddleware, requireAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const policy = await prisma.policyDocument.findFirst({ where: { id: req.params.id, orgId }, select: { id: true, title: true } }) + if (!policy) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '制度不存在' } }) + } + const { employeeIds } = req.body as { employeeIds?: string[] } + const readRecords = await prisma.policyReadRecord.findMany({ where: { policyId: req.params.id, orgId }, select: { employeeId: true } }) + const readEmpIds = new Set(readRecords.map(r => r.employeeId)) + const targetEmployees = await prisma.employee.findMany({ + where: { + orgId, status: 'ACTIVE', + id: employeeIds && employeeIds.length > 0 ? { in: employeeIds } : undefined, + }, + select: { id: true, name: true }, + }) + const unreadEmployees = targetEmployees.filter(e => !readEmpIds.has(e.id)) + // 创建催办通知 + for (const emp of unreadEmployees) { + await prisma.notificationLog.create({ + data: { + orgId, + employeeId: emp.id, + type: 'POLICY_REMIND', + title: `制度签收提醒:${policy.title}`, + content: `您有一项制度「${policy.title}」尚未签收,请尽快完成阅读确认。`, + channel: 'IN_APP', + status: 'SENT', + }, + }).catch(() => {}) + } + res.json({ success: true, data: { reminded: unreadEmployees.length } }) + } catch (err) { + next(err) + } +}) + export default router diff --git a/backend/src/routes/roster.routes.ts b/backend/src/routes/roster.routes.ts index c48beec..368c806 100644 --- a/backend/src/routes/roster.routes.ts +++ b/backend/src/routes/roster.routes.ts @@ -5,6 +5,7 @@ import { createEvidence } from '../services/evidence.service' import prisma from '../lib/prisma' import { decrypt, encrypt } from '../lib/crypto' import { getContractStatus } from '../services/contract.service' +import { calcSocialInsurance, calcHousingFund } from '../services/payroll.service' import ExcelJS from 'exceljs' const router = Router() @@ -120,6 +121,32 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => { }), ]) + // 获取社保和公积金配置(按城市缓存) + const currentMonth = new Date().toISOString().slice(0, 7) + const configCache = new Map() + const getConfigsForCity = async (city?: string) => { + const key = city || '_default' + if (configCache.has(key)) return configCache.get(key)! + const cityWhere = city ? { orgId: req.user!.orgId, city } : { orgId: req.user!.orgId } + const [socialCfg, housingCfg] = await Promise.all([ + prisma.socialInsuranceConfig.findFirst({ + where: { ...cityWhere, effectiveFrom: { lte: currentMonth }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: currentMonth } }] }, + orderBy: { effectiveFrom: 'desc' }, + }), + prisma.housingFundConfig.findFirst({ + where: { ...cityWhere, effectiveFrom: { lte: currentMonth }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: currentMonth } }] }, + orderBy: { effectiveFrom: 'desc' }, + }), + ]) + const result = { social: socialCfg, housing: housingCfg } + configCache.set(key, result) + return result + } + + // 预加载所有涉及城市的配置 + const cities = [...new Set(employees.map((e) => e.city).filter(Boolean))] as string[] + await Promise.all(cities.map((c) => getConfigsForCity(c))) + // 计算动态状态和合同状态 let result = employees.map((e) => { const latestContract = e.contracts[0] || null @@ -173,6 +200,20 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => { idCardMasked, idCardNumber: safeDecryptStr(e.idCardNumber), monthlySalary: safeDecrypt(e.monthlySalary), + socialInsBase: e.socialInsBase, + housingFundBase: e.housingFundBase, + socialInsCalc: (() => { + const cfgs = configCache.get(e.city || '_default') + if (!cfgs?.social || !e.socialInsBase) return null + const r = calcSocialInsurance(e.socialInsBase, cfgs.social) + return { socialEmp: r.socialEmp, socialOrg: r.socialOrg } + })(), + housingFundCalc: (() => { + const cfgs = configCache.get(e.city || '_default') + if (!cfgs?.housing || !e.housingFundBase) return null + const r = calcHousingFund(e.housingFundBase, cfgs.housing) + return { housingEmp: r.housingEmp, housingOrg: r.housingOrg } + })(), isPregnant: e.isPregnant, isInMedicalPeriod: e.isInMedicalPeriod, isWorkInjured: e.isWorkInjured, @@ -767,7 +808,11 @@ router.get('/training/list', authMiddleware, async (req: AuthRequest, res, next) const page = parseInt(req.query.page as string) || 1 const pageSize = parseInt(req.query.pageSize as string) || 20 const keyword = (req.query.keyword as string) || '' + const ackStatus = (req.query.ackStatus as string) || '' const where: any = { orgId } + if (ackStatus) { + where.ackStatus = ackStatus + } if (keyword) { const employees = await prisma.employee.findMany({ where: { orgId, name: { contains: keyword } }, @@ -789,6 +834,35 @@ router.get('/training/list', authMiddleware, async (req: AuthRequest, res, next) } catch (err) { next(err) } }) +// 培训记录催办(发送通知给未签收员工) +router.post('/training/remind/:recordId', authMiddleware, async (req: AuthRequest, res, next) => { + try { + const orgId = req.user!.orgId + const record = await prisma.trainingRecord.findFirst({ + where: { id: req.params.recordId, orgId }, + include: { employee: { select: { id: true, name: true, department: true, phone: true } } }, + }) + if (!record) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '培训记录不存在' } }) + } + if (record.ackStatus !== 'PENDING') { + return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '仅待签收记录可催办' } }) + } + // 记录催办通知日志 + await prisma.notificationLog.create({ + data: { + orgId, + type: 'TRAINING_REMIND', + title: `培训签收催办:${record.topic}`, + content: `员工 ${record.employee.name}(${record.employee.department})的培训记录「${record.topic}」尚未签收,请尽快完成签收。`, + channel: 'SYSTEM', + status: 'SENT', + }, + }) + res.json({ success: true, data: { message: `已催办 ${record.employee.name} 签收「${record.topic}」` } }) + } catch (err) { next(err) } +}) + // 绩效记录列表(全员) router.get('/performance/list', authMiddleware, async (req: AuthRequest, res, next) => { try { @@ -1074,6 +1148,33 @@ router.post('/:employeeId/training', authMiddleware, async (req: AuthRequest, re } catch (err) { next(err) } }) +// 批量创建培训记录 +router.post('/training/batch', authMiddleware, async (req: AuthRequest, res, next) => { + try { + const { employeeIds, trainingDate, topic, content, trainer, duration, remark } = req.body + if (!employeeIds || !Array.isArray(employeeIds) || employeeIds.length === 0) { + return res.json({ success: false, error: { code: 'VALIDATION_ERROR', message: '请至少选择一名员工' } }) + } + const results = await Promise.all(employeeIds.map((empId: string) => + prisma.trainingRecord.create({ + data: { + orgId: req.user!.orgId, + employeeId: empId, + trainingDate: new Date(trainingDate), + topic, + content, + trainer, + duration: duration || 0, + ackStatus: 'PENDING', + remark, + createdBy: req.user!.id, + }, + }) + )) + res.json({ success: true, data: { count: results.length } }) + } catch (err) { next(err) } +}) + router.put('/:employeeId/training/:recordId', authMiddleware, async (req: AuthRequest, res, next) => { try { const { trainingDate, topic, content, trainer, duration, ackStatus, ackDate, attachmentUrl, remark } = req.body @@ -1124,29 +1225,35 @@ router.get('/:employeeId/performance', authMiddleware, async (req: AuthRequest, router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest, res, next) => { try { - const { period, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer } = req.body + const { period, periodType, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer, templateId, dimensionScores } = req.body const record = await prisma.performanceRecord.upsert({ where: { employeeId_period: { employeeId: req.params.employeeId, period } }, create: { orgId: req.user!.orgId, employeeId: req.params.employeeId, period, + periodType: periodType || 'MONTHLY', score: score || 0, grade: grade || 'B', result: result || 'QUALIFIED', summary, improvementPlan, + templateId: templateId || null, + dimensionScores: dimensionScores || undefined, employeeAck: employeeAck || false, ackDate: ackDate ? new Date(ackDate) : null, reviewer, createdBy: req.user!.id, }, update: { + periodType, score, grade, result, summary, improvementPlan, + templateId: templateId || null, + dimensionScores: dimensionScores || undefined, employeeAck, ackDate: ackDate ? new Date(ackDate) : null, reviewer, @@ -1159,7 +1266,7 @@ router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest, router.put('/:employeeId/performance/:recordId', authMiddleware, async (req: AuthRequest, res, next) => { try { - const { period, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer } = req.body + const { period, periodType, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer, templateId, dimensionScores } = req.body const record = await prisma.performanceRecord.findFirst({ where: { id: req.params.recordId, orgId: req.user!.orgId }, }) @@ -1168,11 +1275,14 @@ router.put('/:employeeId/performance/:recordId', authMiddleware, async (req: Aut where: { id: req.params.recordId }, data: { period, + periodType, score, grade, result, summary, improvementPlan, + templateId: templateId || null, + dimensionScores: dimensionScores || undefined, employeeAck, ackDate: ackDate ? new Date(ackDate) : null, reviewer, @@ -1193,6 +1303,72 @@ router.delete('/:employeeId/performance/:recordId', authMiddleware, async (req: } catch (err) { next(err) } }) +// ========== 绩效模板 CRUD ========== + +// 获取模板列表 +router.get('/performance/templates', authMiddleware, async (req: AuthRequest, res, next) => { + try { + const templates = await prisma.performanceTemplate.findMany({ + where: { orgId: req.user!.orgId }, + orderBy: { createdAt: 'desc' }, + }) + res.json({ success: true, data: templates }) + } catch (err) { next(err) } +}) + +// 创建模板 +router.post('/performance/templates', authMiddleware, async (req: AuthRequest, res, next) => { + try { + const { name, description, dimensions, gradeRules, isDefault } = req.body + if (!name || !dimensions || !Array.isArray(dimensions)) { + return res.json({ success: false, error: { code: 'VALIDATION_ERROR', message: '模板名称和考核维度为必填' } }) + } + // 如果设为默认,先取消其他默认 + if (isDefault) { + await prisma.performanceTemplate.updateMany({ where: { orgId: req.user!.orgId, isDefault: true }, data: { isDefault: false } }) + } + const template = await prisma.performanceTemplate.create({ + data: { + orgId: req.user!.orgId, + name, + description, + dimensions, + gradeRules: gradeRules || undefined, + isDefault: isDefault || false, + createdBy: req.user!.id, + }, + }) + res.json({ success: true, data: template }) + } catch (err) { next(err) } +}) + +// 更新模板 +router.put('/performance/templates/:id', authMiddleware, async (req: AuthRequest, res, next) => { + try { + const { name, description, dimensions, gradeRules, isDefault } = req.body + const existing = await prisma.performanceTemplate.findFirst({ where: { id: req.params.id, orgId: req.user!.orgId } }) + if (!existing) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } }) + if (isDefault) { + await prisma.performanceTemplate.updateMany({ where: { orgId: req.user!.orgId, isDefault: true, id: { not: req.params.id } }, data: { isDefault: false } }) + } + const updated = await prisma.performanceTemplate.update({ + where: { id: req.params.id }, + data: { name, description, dimensions, gradeRules: gradeRules || undefined, isDefault }, + }) + res.json({ success: true, data: updated }) + } catch (err) { next(err) } +}) + +// 删除模板 +router.delete('/performance/templates/:id', authMiddleware, async (req: AuthRequest, res, next) => { + try { + const existing = await prisma.performanceTemplate.findFirst({ where: { id: req.params.id, orgId: req.user!.orgId } }) + if (!existing) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } }) + await prisma.performanceTemplate.delete({ where: { id: req.params.id } }) + res.json({ success: true }) + } catch (err) { next(err) } +}) + // ========== 调薪/调部门 API ========== function dateToMonth(date: Date): string { diff --git a/backend/src/routes/template.routes.ts b/backend/src/routes/template.routes.ts index 7ddecfe..5750b4a 100644 --- a/backend/src/routes/template.routes.ts +++ b/backend/src/routes/template.routes.ts @@ -49,10 +49,16 @@ router.get('/:id/download', authMiddleware, async (req: AuthRequest, res: Respon if (!template) { return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } }) } + const htmlContent = ` +${template.name} + +${template.content}` const encoded = encodeURIComponent(template.name + '.doc') - res.setHeader('Content-Type', 'application/msword') + res.setHeader('Content-Type', 'application/msword; charset=utf-8') res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`) - res.send(template.content) + res.send(htmlContent) } catch (err) { next(err) } diff --git a/backend/src/routes/termination.routes.ts b/backend/src/routes/termination.routes.ts index 6c120a8..8316355 100644 --- a/backend/src/routes/termination.routes.ts +++ b/backend/src/routes/termination.routes.ts @@ -340,4 +340,24 @@ router.get('/draft/:id/validate-step', authMiddleware, async (req: AuthRequest, } }) +// 删除草稿(仅允许 DRAFT 和 CANCELLED 状态) +router.delete('/draft/:id', authMiddleware, async (req: AuthRequest, res, next) => { + try { + const record = await prisma.terminationRecord.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId }, + }) + if (!record) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } }) + } + if (record.status !== 'DRAFT' && record.status !== 'CANCELLED') { + return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '仅草稿或已撤销的记录可以删除' } }) + } + await prisma.terminationRecord.delete({ where: { id: req.params.id } }) + await auditLog(req, 'DELETE_DRAFT', 'TERMINATION_RECORD', req.params.id, { employeeId: record.employeeId }) + res.json({ success: true }) + } catch (err) { + next(err) + } +}) + export default router diff --git a/backend/src/schemas/contract.schema.ts b/backend/src/schemas/contract.schema.ts index 3bc172f..f8b4cca 100644 --- a/backend/src/schemas/contract.schema.ts +++ b/backend/src/schemas/contract.schema.ts @@ -14,6 +14,7 @@ export const createEmployeeSchema = z.object({ isWorkInjured: z.boolean().default(false), city: z.string().max(20).optional(), education: z.string().max(20).optional(), + position: z.string().max(50).optional(), contract: z.object({ signDate: z.string().datetime().nullable(), startDate: z.string().datetime(), @@ -48,6 +49,7 @@ export const updateEmployeeSchema = z.object({ specialDeduction: z.number().min(0).optional(), city: z.string().max(20).optional(), education: z.string().max(20).optional(), + position: z.string().max(50).optional(), }) export const batchRenewSchema = z.object({ diff --git a/backend/src/services/attendance.service.ts b/backend/src/services/attendance.service.ts index 56d423c..a6fb82d 100644 --- a/backend/src/services/attendance.service.ts +++ b/backend/src/services/attendance.service.ts @@ -279,8 +279,8 @@ export async function manualCorrectAttendance(orgId: string, data: { where: { orgId, employeeId: data.employeeId, date: { gte: day, lt: nextDay } }, }) - const checkInTime = data.checkInTime ? new Date(`${data.date}T${data.checkInTime}`).toISOString() : null - const checkOutTime = data.checkOutTime ? new Date(`${data.date}T${data.checkOutTime}`).toISOString() : null + const checkInTime = data.checkInTime ? new Date(`${data.date}T${data.checkInTime}:00Z`).toISOString() : null + const checkOutTime = data.checkOutTime ? new Date(`${data.date}T${data.checkOutTime}:00Z`).toISOString() : null let workHours = 0 if (checkInTime && checkOutTime) { @@ -388,10 +388,11 @@ export async function getMonthlyReport(orgId: string, month: string) { orderBy: { name: 'asc' }, }) - const otMap = new Map() + const otMap = new Map() for (const ot of overtimes) { const totalHours = (ot.weekdayHours || 0) + (ot.weekendHours || 0) + (ot.holidayHours || 0) - otMap.set(ot.employeeId, (otMap.get(ot.employeeId) || 0) + totalHours) + const prev = otMap.get(ot.employeeId) || { hours: 0, pay: 0 } + otMap.set(ot.employeeId, { hours: prev.hours + totalHours, pay: prev.pay + (ot.totalPay || 0) }) } const leaveMap = new Map() @@ -412,8 +413,8 @@ export async function getMonthlyReport(orgId: string, month: string) { earlyLeaveCount: empRecords.filter(r => r.status === 'EARLY_LEAVE').length, absentDays: empRecords.filter(r => r.status === 'ABSENT').length, leaveDays: leaveMap.get(emp.id) || 0, - overtimeHours: confirmation ? (confirmation.weekdayHours + confirmation.weekendHours + confirmation.holidayHours) : (otMap.get(emp.id) || 0), - overtimePay: confirmation?.overtimePay || 0, + overtimeHours: confirmation ? (confirmation.weekdayHours + confirmation.weekendHours + confirmation.holidayHours) : (otMap.get(emp.id)?.hours || 0), + overtimePay: confirmation?.overtimePay || otMap.get(emp.id)?.pay || 0, confirmationStatus: confirmation?.status || null, } }) diff --git a/backend/src/services/contract.service.ts b/backend/src/services/contract.service.ts index e0352c9..ca02620 100644 --- a/backend/src/services/contract.service.ts +++ b/backend/src/services/contract.service.ts @@ -13,6 +13,24 @@ function dateToMonth(date: Date): string { return `${y}-${m}` } +async function clampSocialInsBase(orgId: string, base: number, city?: string): Promise { + const config = await prisma.socialInsuranceConfig.findFirst({ + where: { orgId, ...(city ? { city } : {}) }, + orderBy: { effectiveFrom: 'desc' }, + }) + if (config) return Math.min(Math.max(base, config.baseMin), config.baseMax) + return base +} + +async function clampHousingFundBase(orgId: string, base: number, city?: string): Promise { + const config = await prisma.housingFundConfig.findFirst({ + where: { orgId, ...(city ? { city } : {}) }, + orderBy: { effectiveFrom: 'desc' }, + }) + if (config) return Math.min(Math.max(base, config.baseMin), config.baseMax) + return base +} + function prevMonth(month: string): string { const [y, m] = month.split('-').map(Number) const d = new Date(y, m - 2, 1) @@ -191,6 +209,17 @@ export async function getEmployeeDetail(orgId: string, id: string) { } export async function createEmployee(orgId: string, userId: string, data: any) { + // 身份证号查重 + if (data.idCardNumber) { + const existing = await prisma.employee.findFirst({ + where: { orgId, idCardHash: sha256(data.idCardNumber) }, + select: { id: true, name: true, department: true, status: true }, + }) + if (existing) { + throw { code: 'DUPLICATE_ID_CARD', message: `身份证号已存在:${existing.name}(${existing.department},${existing.status === 'ACTIVE' ? '在职' : '离职'}),请确认是否重复录入` } + } + } + const org = await prisma.organization.findUnique({ where: { id: orgId } }) if (org && org.maxEmployees > 0) { const activeCount = await prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }) @@ -202,8 +231,11 @@ export async function createEmployee(orgId: string, userId: string, data: any) { const hireDate = new Date(data.hireDate) const hireMonth = dateToMonth(hireDate) const salaryNum = Number(data.monthlySalary) || 0 - const socialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum - const housingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum + const city = data.city || '北京' + const rawSocialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum + const rawHousingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum + const socialInsBase = await clampSocialInsBase(orgId, rawSocialInsBase, city) + const housingFundBase = await clampHousingFundBase(orgId, rawHousingFundBase, city) const socialInsStartMonth = data.socialInsStartMonth || hireMonth const housingFundStartMonth = data.housingFundStartMonth || hireMonth @@ -230,6 +262,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) { createdBy: userId, city: data.city || '北京', education: data.education || null, + position: data.position || null, }, }) @@ -346,8 +379,11 @@ export async function rehireEmployee(orgId: string, userId: string, id: string, const newHireMonth = dateToMonth(newHireDate) const salaryNum = Number(decrypt(employee.monthlySalary)) || 0 - const socialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum - const housingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum + const city = data.city || employee.city || '北京' + const rawSocialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum + const rawHousingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum + const socialInsBase = await clampSocialInsBase(orgId, rawSocialInsBase, city) + const housingFundBase = await clampHousingFundBase(orgId, rawHousingFundBase, city) const socialInsStartMonth = data.socialInsStartMonth || newHireMonth const housingFundStartMonth = data.housingFundStartMonth || newHireMonth const prevHireMonth = prevMonth(newHireMonth) @@ -543,11 +579,18 @@ export async function updateEmployee(orgId: string, id: string, data: any) { if (data.isPregnant !== undefined) updateData.isPregnant = data.isPregnant if (data.isInMedicalPeriod !== undefined) updateData.isInMedicalPeriod = data.isInMedicalPeriod if (data.isWorkInjured !== undefined) updateData.isWorkInjured = data.isWorkInjured - if (data.socialInsBase !== undefined) updateData.socialInsBase = data.socialInsBase - if (data.housingFundBase !== undefined) updateData.housingFundBase = data.housingFundBase + if (data.socialInsBase !== undefined) { + const city = data.city || employee.city || '北京' + updateData.socialInsBase = await clampSocialInsBase(orgId, Number(data.socialInsBase), city) + } + if (data.housingFundBase !== undefined) { + const city = data.city || employee.city || '北京' + updateData.housingFundBase = await clampHousingFundBase(orgId, Number(data.housingFundBase), city) + } if (data.specialDeduction !== undefined) updateData.specialDeduction = data.specialDeduction if (data.city !== undefined) updateData.city = data.city if (data.education !== undefined) updateData.education = data.education + if (data.position !== undefined) updateData.position = data.position // 参保城市变更:关闭旧城市在保记录,创建新城市记录 if (data.city !== undefined && data.city !== employee.city) { diff --git a/backend/src/services/evidence.service.ts b/backend/src/services/evidence.service.ts index b4299fa..d4be65b 100644 --- a/backend/src/services/evidence.service.ts +++ b/backend/src/services/evidence.service.ts @@ -185,6 +185,7 @@ export async function verifyAllEvidence(orgId: string) { const records = await prisma.evidenceChain.findMany({ where: { orgId } }) let valid = 0 let invalid = 0 + const invalidItems: any[] = [] for (const r of records) { const events = r.events as any[] const eventsJson = JSON.stringify(events) @@ -198,8 +199,16 @@ export async function verifyAllEvidence(orgId: string) { }) const sortedJson = JSON.stringify(sortedEvents) const sortedHash = sha256(sortedJson + orgId + r.category + (r.refId || '')) - if (sortedHash === r.hash) valid++ - else invalid++ + if (sortedHash === r.hash) { valid++; continue } + invalid++ + invalidItems.push({ + id: r.id, + category: r.category, + refId: r.refId, + employeeId: r.employeeId, + createdAt: r.createdAt.toISOString(), + description: `证据链 ${r.category}${r.refId ? `(${r.refId})` : ''} 哈希校验失败,可能被篡改`, + }) } - return { total: records.length, valid, invalid } + return { total: records.length, valid, invalid, invalidItems } } diff --git a/backend/src/services/work-process.service.ts b/backend/src/services/work-process.service.ts index 712f563..7efe6d1 100644 --- a/backend/src/services/work-process.service.ts +++ b/backend/src/services/work-process.service.ts @@ -260,29 +260,31 @@ export async function generateDocument(type: string, formData: any, orgName: str } } + const wrapHtml = (title: string, body: string) => ` +${title} + + +
${title}
+${body} +` + const templates: Record string> = { - INCOME_CERT: (data, org) => `收入证明 - -兹证明 ${data.employeeName || '___'}(身份证号:${data.idCardNumber || '___'})系我单位员工,自 ${data.hireDate || '___'} 起在我单位工作,现任 ${data.position || '___'} 职务。 - -该员工近一年平均月收入为人民币 ${data.monthlyIncome || '___'} 元(税前)。 - -本证明仅用于 ${data.purpose || '___'},不作其他用途。 - -特此证明。 - -${org} -${new Date().toLocaleDateString('zh-CN')}`, - LEAVING_CERT: (data, org) => `离职证明 - -兹证明 ${data.employeeName || '___'}(身份证号:${data.idCardNumber || '___'})自 ${data.hireDate || '___'} 至 ${data.leaveDate || '___'} 在我单位工作,最后职务为 ${data.position || '___'}。 - -该员工已于 ${data.leaveDate || '___'} 与我单位解除劳动关系,双方已办妥交接手续。 - -特此证明。 - -${org} -${new Date().toLocaleDateString('zh-CN')}`, + INCOME_CERT: (data, org) => wrapHtml('收入证明', ` +
兹证明 ${data.employeeName || '___'}(身份证号:${data.idCardNumber || '___'})系我单位员工,自 ${data.hireDate || '___'} 起在我单位工作,现任 ${data.position || '___'} 职务。
+
该员工近一年平均月收入为人民币 ${data.monthlyIncome || '___'} 元(税前)。
+
本证明仅用于 ${data.purpose || '___'},不作其他用途。
+
特此证明。
+
${org}
${new Date().toLocaleDateString('zh-CN')}
`), + LEAVING_CERT: (data, org) => wrapHtml('离职证明', ` +
兹证明 ${data.employeeName || '___'}(身份证号:${data.idCardNumber || '___'})自 ${data.hireDate || '___'} 至 ${data.leaveDate || '___'} 在我单位工作,最后职务为 ${data.position || '___'}。
+
该员工已于 ${data.leaveDate || '___'} 与我单位解除劳动关系,双方已办妥交接手续。
+
特此证明。
+
${org}
${new Date().toLocaleDateString('zh-CN')}
`), } const generator = templates[type] if (!generator) return { name: '', content: '' } diff --git a/docs/20260809-优化.md b/docs/20260809-优化.md new file mode 100644 index 0000000..b424efa --- /dev/null +++ b/docs/20260809-优化.md @@ -0,0 +1,867 @@ +# 20260809 优化需求清单 + +> 基于用户反馈整理,共 28 项问题,按模块和优先级分类。 +> +> **代码审查更新**:2026-08-09 完成全量代码核查,补充实际代码定位和确认结果。 + +--- + +## 一、员工福利模块 + +### 问题1:福利方案创建后无法添加享受人员,批量参保无人员数据 + +**模块**:员工福利 +**优先级**:P0 +**状态**:待验证 + +**现状描述**: +创建好福利方案后,无法增加享受福利的人员,批量参保时无人员数据可选。 + +**代码核查结果**: +功能实际已实现。`EmployeeBenefits.tsx` 中有完整的批量参保功能: +- 点击福利方案卡片可展开参保人员列表(`EmployeeBenefits.tsx:228`) +- 「批量参保」按钮打开 Modal,加载花名册在职员工列表(`:232`) +- 支持全选/勾选员工,设置生效月份,提交参保(`:395-456`) +- `rosterApi.list` 查询 `pageSize: 200` 条员工数据(`:78`) + +**潜在问题**:`rosterData` 查询仅在 `showEnrollModal` 为 true 时启用(`enabled: showEnrollModal`),如果员工超过200人则无法全部加载。建议改用不分页的 `allLite` 接口。 + +**涉及文件**: +- `frontend/src/pages/EmployeeBenefits.tsx` 福利方案和批量参保 +- `frontend/src/lib/api-services.ts` benefitApi 定义 +- `backend/src/routes/benefits.routes.ts` + +**优化方案**: +1. 批量参保的员工列表改用 `allLite` 接口,避免200条限制 +2. 增加按部门筛选功能 +3. 验证实际运行时员工列表是否正常加载 + +--- + +## 二、全局通用问题 + +### 问题2:多个模块中每页条数选择无反应 + +**模块**:全局(花名册、薪税、考勤等多个列表页) +**优先级**:P1 +**状态**:待验证 + +**现状描述**: +多个模块列表页底部的「每页条数」选择器点击后无反应,无法切换每页显示条数。 + +**代码核查结果**: +`usePageSize` hook(`frontend/src/hooks/usePageSize.ts:1-18`)通过 `localStorage` 持久化,并通过 `page-size-changed` 自定义事件实现跨页面响应。`Pagination` 组件(`frontend/src/components/ui/Pagination.tsx:44-53`)在 `onPageSizeChange` 时触发回调。 + +**疑似问题**:多个列表页在 `onPageSizeChange` 回调中仅调用 `setPage(1)` 但未显式传递新的 `pageSize` 值。例如 `Evidence.tsx:132`: +```tsx +onPageSizeChange={() => setPage(1)} +``` +由于 `usePageSize` hook 返回的 `pageSize` 是全局状态,变更后自动触发 queryKey 变化,理论上应该能工作。需实际运行验证事件监听是否在所有页面正确触发重渲染。 + +**涉及文件**: +- `frontend/src/hooks/usePageSize.ts:1-18` 全局 pageSize 状态管理 +- `frontend/src/lib/pageSize.ts:1-21` getPageSize/setPageSize 工具函数 +- `frontend/src/components/ui/Pagination.tsx:44-53` 分页组件 +- `frontend/src/pages/Settings.tsx:15-245` 全局设置页 +- `frontend/src/pages/AuditLog.tsx:118-246` 使用示例 +- `frontend/src/pages/Evidence.tsx:127-133` 疑似问题点 + +**优化方案**: +1. 验证 `usePageSize` 的 `page-size-changed` 事件是否在所有页面正确触发 +2. 确保所有列表页 `onPageSizeChange` 回调中 `setPage(1)` 后 queryKey 包含 `pageSize` +3. 全局统一分页组件,确保所有列表页行为一致 + +--- + +## 三、离职管理模块 + +### 问题3:离职证明下载内容为乱码 + +**模块**:离职管理 +**优先级**:P0 +**状态**:待修复 + +**现状描述**: +离职管理中下载的离职证明文件内容是一团乱码,无法正常阅读。 + +**问题分析**: +- `work-process.service.ts` 生成的 `.doc` 文件为纯文本格式,Word 打开时可能出现编码问题 +- 文件下载时 `Content-Type` 和编码声明可能不正确 +- 前端下载方式可能未正确处理二进制流 + +**涉及文件**: +- `backend/src/services/work-process.service.ts:246-290` .doc 文件生成 +- `backend/src/routes/work-process.routes.ts` 下载接口 +- `frontend/src/pages/WorkProcess.tsx` 下载逻辑 +- `frontend/src/pages/Termination.tsx:330-400` 离职管理页面 + +**优化方案**: +1. 在生成的 `.doc` 内容头部添加 BOM 标记(`\uFEFF`),确保 Word 正确识别 UTF-8 编码 +2. 后端下载接口设置正确的 `Content-Type: application/msword; charset=utf-8` +3. 前端下载时使用 Blob 并指定编码 +4. 考虑生成 HTML 格式的 Word 文件(带 `xmlns:o` 命名空间),确保格式正确 + +--- + +### 问题4:离职管理导出数据缺少筛选条件 + +**模块**:离职管理 +**优先级**:P1 +**状态**:待优化 + +**现状描述**: +离职管理导出数据时一次性导出全部数据,无法按时间范围等条件筛选导出。 + +**问题分析**: +- 导出接口未接收前端筛选参数,直接查询全部离职记录 +- 前端导出按钮未传递当前筛选条件 + +**涉及文件**: +- `backend/src/routes/export.routes.ts` 导出接口(含 terminations 导出) +- `backend/src/routes/termination.routes.ts` 离职路由 +- `frontend/src/pages/Termination.tsx:330-400` 导出按钮 +- `frontend/src/lib/api-services.ts:613-696` terminationApi 定义 + +**优化方案**: +1. 导出接口增加 `dateFrom`、`dateTo`、`department`、`status` 等查询参数 +2. 前端导出时携带当前筛选条件 +3. 增加导出确认弹窗,显示筛选范围和预计条数 + +--- + +### 问题5:已提交的离职数据无法撤回,已撤回的无用数据无法删除 + +**模块**:离职管理 +**优先级**:P1 +**状态**:待修复 + +**现状描述**: +离职管理中已提交的数据无法撤回操作,已撤回的无用数据无法删除清理。 + +**代码核查结果**: +- 后端 `termination.service.ts:256-334` 有 `revokeTermination` 方法,路由 `termination.routes.ts:81-115` 有 `DELETE /:id/revoke` 端点 +- 前端 `api-services.ts:613-696` 有 `revoke` 方法定义 +- **但前端 `Termination.tsx` 页面未暴露撤回和删除草稿的按钮**——UI 缺少对应操作入口 +- 后端有 `cancelTermination`(`termination.service.ts:729-954`)和 `getDrafts` 方法 + +**涉及文件**: +- `backend/src/services/termination.service.ts:256-334` revokeTermination +- `backend/src/services/termination.service.ts:729-954` cancelTermination, getDrafts +- `backend/src/routes/termination.routes.ts:81-115` 撤回路由 +- `backend/src/routes/termination.routes.ts:128-234` 草稿管理路由 +- `frontend/src/lib/api-services.ts:613-696` terminationApi.revoke/cancel +- `frontend/src/pages/Termination.tsx:330-400` **缺少撤回/删除按钮** + +**优化方案**: +1. 前端 `Termination.tsx` 为已提交但未完成的离职流程增加「撤回」按钮 +2. 已撤回的草稿数据允许删除,增加二次确认 +3. 已完成离职的记录保留不可删除(合规要求) + +--- + +### 问题6:用工办理中离职/解聘与离职管理模块重复 + +**模块**:用工办理 / 离职管理 +**优先级**:P2 +**状态**:待优化 + +**现状描述**: +用工办理中有员工离职、解聘功能,同时还有独立的离职管理模块,功能重复,显得混乱。 + +**代码核查结果**: +- `WorkProcess.tsx:37-40` 包含 `TERMINATE`(合同终止)、`RESCIND`(合同解除)、`LEAVING_CERT`(离职证明)等流程类型 +- `Termination.tsx` 是独立的离职管理页面,含草稿管理、审批、执行等完整流程 +- 两个入口功能确实重叠 + +**涉及文件**: +- `frontend/src/pages/WorkProcess.tsx:37-40` 流程类型定义 +- `frontend/src/pages/Termination.tsx:330-400` 离职管理页面 +- `frontend/src/components/layout/SidebarNav.tsx` + +**优化方案**: +1. 用工办理中保留「入职办理」「转正」「调岗」等入职相关流程 +2. 离职、解聘相关流程统一归入「离职管理」模块 +3. 侧边栏菜单分组明确:用工办理(入职类)→ 离职管理(离职类) + +--- + +## 四、考勤管理模块 + +### 问题7:考勤导入模板包含无关Sheet,且加班/违纪/考勤三个Sheet需合并 + +**模块**:考勤管理 +**优先级**:P0 +**状态**:待优化 + +**现状描述**: +导入考勤的模板包含「员工信息」和「劳动合同」两个无关 Sheet,只录入考勤信息无法导入。加班记录、违纪记录、考勤记录三个 Sheet 录入同一人员时需重复粘贴姓名与身份证号,应合并。 + +**代码核查结果**: +- `backend/src/routes/import.routes.ts:494-692` 模板下载接口生成包含:员工信息、劳动合同、考勤记录、加班记录、违纪记录等多个 Sheet +- `gen_import_sample.py:50-73` Python 脚本也生成了包含多余 Sheet 的示例文件 +- 导入接口 `import.routes.ts` 处理 `考勤记录`、`加班记录`、`违纪记录`、`薪资调整`、`社保变动`、`公积金变动` 等多个 Sheet +- **确认**:模板确实包含无关的员工信息和劳动合同 Sheet + +**涉及文件**: +- `backend/src/routes/import.routes.ts:494-692` 模板下载和导入处理 +- `frontend/src/pages/Attendance.tsx:525-610` 前端导入弹窗 +- `gen_import_sample.py:50-73` 示例文件生成脚本 + +**优化方案**: +1. 考勤导入模板只保留考勤相关 Sheet,移除员工信息和劳动合同 Sheet +2. 将考勤记录、加班记录合并为一个 Sheet,用列区分(日期、班次、签到时间、签退时间、加班时长等) +3. 违纪记录因字段差异较大,可保留独立 Sheet 或独立导入入口 +4. 每项业务(考勤、加班、违纪)提供独立的专用模板下载 + +--- + +### 问题8:补卡无法修改未打卡状态,签到签退时间显示有问题 + +**模块**:考勤管理 - 每日出勤 +**优先级**:P0 +**状态**:待修复 + +**现状描述**: +考勤排班中每日出勤页面,操作补卡时无法修改未打卡状态,且签到与签退的时间显示有异常。 + +**代码核查结果**: +- `backend/src/services/attendance.service.ts:264-360` 的 `manualCorrectAttendance` 方法支持更新考勤记录,可设置签到/签退时间和状态 +- `backend/src/routes/attendance.routes.ts:210-233` 有 `POST /manual-correct` 端点 +- `frontend/src/pages/Attendance.tsx:970-1174` 的 DailyTab 有补卡弹窗和按钮 +- `frontend/src/lib/api-services.ts:247-297` 有 `manualCorrect` API 调用 +- 考勤状态常量定义在 `Attendance.tsx:25-33`:NORMAL/LATE/EARLY_LEAVE/ABSENT/LEAVE/BUSINESS_TRIP/UNREGISTERED +- **需确认**:补卡弹窗是否限制了状态选项(未覆盖 UNREGISTERED→其他状态的修正),以及时间格式化是否有时区问题 + +**涉及文件**: +- `frontend/src/pages/Attendance.tsx:25-33` 状态常量定义 +- `frontend/src/pages/Attendance.tsx:970-1174` DailyTab 补卡弹窗 +- `frontend/src/lib/api-services.ts:247-297` attendanceApi.manualCorrect +- `backend/src/services/attendance.service.ts:264-360` manualCorrectAttendance +- `backend/src/routes/attendance.routes.ts:210-233` 补卡路由 + +**优化方案**: +1. 补卡弹窗允许修改所有考勤状态(包括未打卡→已打卡/请假/出差等) +2. 检查时间字段的时区处理,确保显示本地时间 +3. 签到签退时间统一格式化为 `HH:mm` 格式 + +--- + +### 问题9:加班费计算与考勤不关联,需重复导入 + +**模块**:考勤管理 / 薪税管理 +**优先级**:P1 +**状态**:待优化 + +**现状描述**: +加班费计算时需要再导入一遍考勤数据,与考勤管理模块的数据不关联。 + +**问题分析**: +- 加班费计算模块可能独立于考勤管理,未从已有的考勤记录中读取加班时长 +- 考勤管理中的加班数据未传递到薪税计算的加班费环节 + +**涉及文件**: +- `frontend/src/pages/money/` 加班费相关组件 +- `backend/src/routes/payroll2.routes.ts` 加班费计算逻辑 +- `backend/src/routes/attendance.routes.ts` 考勤数据查询 +- `backend/src/routes/import.routes.ts` 考勤导入(含加班记录 Sheet) + +**优化方案**: +1. 加班费计算改为从考勤管理模块读取已确认的加班记录 +2. 薪税批次创建时自动拉取当月考勤加班数据,无需重复导入 +3. 保留手动导入作为备选方案 + +--- + +### 问题10:个人考勤记录添加后加班汇总不显示 + +**模块**:考勤管理 +**优先级**:P1 +**状态**:待修复 + +**现状描述**: +个人考勤记录添加时手动填写了加班时长,但加班汇总中不显示条数,不清楚加班汇总关联的是哪里。 + +**问题分析**: +- 加班汇总可能统计的是考勤导入的加班数据,而非手动添加的加班时长 +- 加班汇总的数据源与个人考勤记录的加班字段未关联 + +**涉及文件**: +- `frontend/src/pages/Attendance.tsx:970-1174` 加班汇总和考勤记录 +- `backend/src/routes/attendance.routes.ts` 加班统计接口 + +**优化方案**: +1. 加班汇总统计应包含手动添加的考勤记录中的加班时长 +2. 加班汇总增加数据来源标识(导入/手动添加) +3. 明确加班汇总与考勤记录的关联关系,UI 上增加说明 + +--- + +## 五、证据链模块 + +### 问题11:验证全部完整性功能简陋,无法定位异常 + +**模块**:证据链 +**优先级**:P1 +**状态**:待优化 + +**现状描述**: +证据链中「验证全部完整性」功能验证后显示异常,但无法告知哪部分异常,下方提醒也无法跳转操作。 + +**代码核查结果**: +- `frontend/src/pages/Evidence.tsx:31-37` 调用 `evidenceApi.verifyAll()`,返回结果仅显示 `total`、`valid`、`invalid` 三个数字(`:60-75`) +- 无详细异常项列表,无跳转操作 +- `frontend/src/lib/api-services.ts:700-707` `evidenceApi` 定义了 `list` 和 `verifyAll` 方法 +- `frontend/src/pages/roster/EvidenceChain.tsx:1-155` 是员工个人维度的仲裁证据链,展示证据列表、风险提醒和导出功能 + +**涉及文件**: +- `frontend/src/pages/Evidence.tsx:31-75` 验证全部完整性功能 +- `frontend/src/lib/api-services.ts:700-707` evidenceApi 定义 +- `frontend/src/pages/roster/EvidenceChain.tsx:1-155` 员工个人证据链 +- `backend/src/routes/roster.routes.ts` 证据链验证接口 + +**优化方案**: +1. 验证接口返回详细的检查项列表(每项:名称、状态、异常描述) +2. 前端展示验证结果明细,异常项高亮显示 +3. 每个异常项增加「去处理」跳转按钮,跳转到对应模块 + +--- + +## 六、规章制度管理 + +### 问题12:规章制度签收缺少催办和未签收人员查看 + +**模块**:规章制度 +**优先级**:P1 +**状态**:待优化 + +**现状描述**: +规章制度向员工公示后,签收只显示签收人数和占比,无法查看具体未签收人员,也无法催办。 + +**代码核查结果**: +- `frontend/src/pages/Policies.tsx:249-285` 有 `ReadStats` 组件,展示签收百分比和未签收人数 +- 已签收人员列表可展开查看(`:278-285`),显示姓名、部门、签收时间 +- **缺少催办通知功能**——无催办按钮 +- **未签收人员列表未展示**——仅显示未签收人数(`:273-277`),未列出具体人员 +- `frontend/src/lib/api-services.ts:674-696` `policiesApi.readStats` 返回 `readCount`、`total`、`unreadCount`、`records` +- 员工端 `frontend/src/pages/portal/MyPolicies.tsx:36-46` 有阅读确认 mutation 和待签收数量统计 + +**涉及文件**: +- `frontend/src/pages/Policies.tsx:110-120` 签收进度条 +- `frontend/src/pages/Policies.tsx:246-285` ReadStats 组件 +- `frontend/src/lib/api-services.ts:674-696` policiesApi 定义 +- `frontend/src/pages/portal/MyPolicies.tsx:30-50` 员工端阅读确认 +- `backend/src/routes/regulations.routes.ts` + +**优化方案**: +1. 签收统计增加「查看明细」按钮,展开已签收/未签收人员列表 +2. 未签收人员列表支持「一键催办」,发送通知提醒员工签收 +3. 显示每位员工的签收状态和时间 + +--- + +## 七、文本模板模块 + +### 问题13:新建模板不支持导入文档,现有方式易造成格式混乱 + +**模块**:文本模板 +**优先级**:P1 +**状态**:待优化 + +**现状描述**: +文本模板新建时只能手动输入内容,无法通过导入 Word 文档创建,现有方式容易造成格式混乱,需要保留导入文档的原始格式。 + +**代码核查结果**: +- `frontend/src/pages/Templates.tsx:303-571` 的 `EnterpriseTemplates` 组件中,新建模板仅支持 `textarea` 手动输入内容(`:493-498`) +- 模板内容使用 `{{变量名}}` 占位符,支持变量替换渲染 +- 系统模板支持下载 Word(`.doc` 格式),通过 `fetch` 请求 `/templates/:id/download` +- `frontend/src/lib/api-services.ts:814-839` `templatesApi` 无文档导入接口 +- **确认**:无文档上传入口,不支持导入 `.docx` 文件 + +**涉及文件**: +- `frontend/src/pages/Templates.tsx:1-571` 模板管理页面(系统模板+企业模板) +- `frontend/src/lib/api-services.ts:814-839` templatesApi 定义 +- `backend/src/routes/templates.routes.ts` + +**优化方案**: +1. 新建模板增加「导入文档」入口,支持上传 `.docx` 文件 +2. 后端使用 `mammoth` 或类似库解析 Word 文档,保留段落、表格等结构 +3. 导入后转为 HTML 存储模板内容,前端预览时保留格式 +4. 保留现有手动创建方式作为备选 + +--- + +## 八、花名册模块 + +### 问题14:录入工资后社保基数自动取工资数,选择参保地后未自动封上下限 + +**模块**:花名册 +**优先级**:P1 +**状态**:待优化 + +**现状描述**: +花名册单独录入员工时,社保基数自动取工资数可以,但如果选择参保地,计算时未能自动封上下限。 + +**代码核查结果**: +- `frontend/src/pages/roster/modals.tsx:680-681` 社保基数默认取月工资:`value={form.socialInsBase || form.monthlySalary}` +- `socialInsuranceApi.cities()` 已获取城市列表(`modals.tsx:493-498`) +- `socialInsuranceApi.calculate(base, city)` 可计算社保费用(`api-services.ts:510-512`) +- **确认**:未根据参保城市查询基数上下限进行封顶/封底处理 +- `frontend/src/pages/roster/BasicInfo.tsx:74` 显示社保基数,编辑时为普通输入框(`:344-345`) + +**涉及文件**: +- `frontend/src/pages/roster/modals.tsx:486-767` AddEmployeeModal 社保基数填充 +- `frontend/src/pages/roster/modals.tsx:235-484` RehireModal 社保基数填充 +- `frontend/src/pages/roster/BasicInfo.tsx:60-120` 编辑表单 +- `frontend/src/lib/api-services.ts:510-520` socialInsuranceApi + +**优化方案**: +1. 选择参保地后,自动查询该城市的社保基数上下限 +2. 社保基数 = min(max(工资数, 下限), 上限) +3. 如果工资数在上下限范围内,直接取工资数;否则显示封顶/封底后的值并提示 + +--- + +### 问题15:社保基数手动修改时原有数据不能直接覆盖 + +**模块**:花名册 +**优先级**:P2 +**状态**:待修复 + +**现状描述**: +社保基数自动取工资后实际不是社保基数时需要手动修改,但修改时原有数据不能删除,必须用鼠标点击选中后再修改,影响录入效率。 + +**代码核查结果**: +- `frontend/src/pages/roster/modals.tsx:681` 使用 `value={form.socialInsBase || form.monthlySalary}`,当 `socialInsBase` 为空时回退到 `monthlySalary` +- 用户清空输入框时 `socialInsBase` 变为空字符串,又回退到 `monthlySalary`,无法真正清空 +- **缺少 `onFocus={(e) => e.target.select()}` 聚焦全选功能** +- `BasicInfo.tsx:344-345` 编辑模式下的社保基数输入框为普通 `Input`,无自动回退问题 + +**涉及文件**: +- `frontend/src/pages/roster/modals.tsx:680-681` AddEmployeeModal 社保基数输入框 +- `frontend/src/pages/roster/modals.tsx:397-398` RehireModal 社保基数输入框 +- `frontend/src/pages/roster/BasicInfo.tsx:344-345` 编辑表单社保基数输入框 + +**优化方案**: +1. 社保基数输入框改为受控组件,自动填充后用户可直接输入覆盖 +2. 输入框获得焦点时自动全选当前值,方便直接覆盖 +3. 增加 `onFocus={(e) => e.target.select()}` 实现聚焦全选 + +--- + +### 问题16:录入校验失败未指明具体字段 + +**模块**:花名册 +**优先级**:P1 +**状态**:待优化 + +**现状描述**: +录入员工时可能是手机号录入有问题,但系统只提示「校验失败」,不指出哪个字段校验失败。 + +**代码核查结果**: +- `backend/src/schemas/contract.schema.ts:3-27` `createEmployeeSchema` 定义了字段级 Zod 校验规则,如 `phone: z.string().regex(/^1[3-9]\d{9}$/)` +- 前端 `modals.tsx:648-650` 错误处理仅显示通用消息:`{error.response?.data?.error?.message || '操作失败'}` +- **未解析 Zod 返回的字段级错误信息并在对应字段下方显示** +- `backend/src/middleware/errorHandler.ts:27-31` P2002 唯一约束错误返回通用"数据已存在,请勿重复操作" + +**涉及文件**: +- `backend/src/schemas/contract.schema.ts:1-72` Zod 校验 schema 定义 +- `backend/src/middleware/errorHandler.ts:27-31` 错误处理中间件 +- `backend/src/routes/employee.routes.ts:99-126` 创建/更新员工路由 +- `frontend/src/pages/roster/modals.tsx:648-650` AddEmployeeModal 错误提示 +- `frontend/src/pages/roster/BasicInfo.tsx:82-119` 编辑表单错误处理 + +**优化方案**: +1. 后端校验失败时返回具体字段名和错误原因(如 `{"field": "phone", "message": "手机号格式不正确"}`) +2. 前端解析错误信息,在对应字段下方显示红色提示 +3. toast 提示中包含具体字段名 + +--- + +### 问题17:花名册员工详情中薪税入口意义不明 + +**模块**:花名册 +**优先级**:P2 +**状态**:待优化 + +**现状描述**: +花名册员工个人详情中的小标识第二个点进去直接进入薪税模块(批次发薪),不理解放在员工个人这里的意义,应该是与此员工有关的个人薪资关联。 + +**代码核查结果**: +- `frontend/src/pages/roster/EmployeeProfile.tsx:66` 中 `payslip` tab 展示 `PayslipSocialInfo`,显示该员工的工资条和社保记录 +- `EmployeeProfileShell.tsx:12-17` 员工 profile 类型定义包含 `position` 字段 +- 需确认是否有跳转到薪税批次列表页的入口 + +**涉及文件**: +- `frontend/src/pages/roster/EmployeeProfile.tsx:60-71` tab 定义 +- `frontend/src/pages/roster/EmployeeProfileShell.tsx:12-17` profile 类型 +- `frontend/src/pages/roster/BasicInfo.tsx` 快捷入口 + +**优化方案**: +1. 改为跳转到该员工的个人薪资历史记录页面 +2. 或在员工详情中增加「薪资历史」标签页,展示该员工所有批次的工资条 + +--- + +### 问题18:花名册列表有职务列,但录入时无职务字段 + +**模块**:花名册 +**优先级**:P1 +**状态**:待修复 + +**现状描述**: +花名册主页显示有职务这一栏,但单独录入员工时却没有职务这一项。 + +**代码核查结果(确认)**: +- 花名册列表 `Roster.tsx:29` 有 `position` 列(职务),`:500` 有表头,`:560` 有数据渲染 +- `AddEmployeeModal`(`modals.tsx:486-767`)表单中**无 `position` 字段** +- `BasicInfo.tsx` 编辑表单中也**无 `position` 字段** +- `createEmployeeSchema`(`contract.schema.ts:3-27`)中**无 `position` 字段** +- `updateEmployeeSchema`(`contract.schema.ts:29-51`)中也**无 `position` 字段** +- 后端 `createEmployee`(`contract.service.ts:193-272`)中也**未设置 `position` 字段** +- **但后端查询时 select 包含 `position`**(`employee.routes.ts:56,82`),说明数据库有此字段 +- `EmployeeProfileShell.tsx:15` 类型定义包含 `position`,`:147-149` 显示 position + +**涉及文件**: +- `frontend/src/pages/Roster.tsx:29,500,560` 列表显示职务列 +- `frontend/src/pages/roster/modals.tsx:486-767` AddEmployeeModal **缺少 position 字段** +- `frontend/src/pages/roster/BasicInfo.tsx:60-120` 编辑表单 **缺少 position 字段** +- `backend/src/schemas/contract.schema.ts:3-51` **缺少 position 字段** +- `backend/src/services/contract.service.ts:193-272` createEmployee **未设置 position** +- `backend/src/routes/employee.routes.ts:56,82` 查询时 select 包含 position +- `frontend/src/pages/roster/EmployeeProfileShell.tsx:15,147-149` profile 显示 position + +**优化方案**: +1. `AddEmployeeModal` 和 `BasicInfo` 编辑表单增加「职务」字段 +2. `createEmployeeSchema` 和 `updateEmployeeSchema` 增加 `position: z.string().max(50).optional()` +3. `createEmployee` 和 `updateEmployee` 服务中设置 `position` 字段 + +--- + +### 问题19:花名册中社保费用计算与社保模块不一致 + +**模块**:花名册 / 社保管理 +**优先级**:P1 +**状态**:待修复 + +**现状描述**: +花名册里员工个人计算的社保费用与社保模块中不一致。社保模块里已修改了养老医保基数不一致,但花名册里计算还是保持一致。 + +**代码核查结果**: +- `BasicInfo.tsx:325-330` 显示社保缴费基数和公积金缴费基数,使用统一基数 +- `BasicInfo.tsx:364-368` 未设置基数时显示警告提示 +- `contract.service.ts:205-206` 创建员工时 `socialInsBase` 和 `housingFundBase` 均默认取 `salaryNum` +- `api-services.ts:510-512` `socialInsuranceApi.calculate(base, city)` 使用统一 base 计算 +- **确认**:花名册使用统一基数,未读取社保模块中按险种分别配置的基数 + +**涉及文件**: +- `frontend/src/pages/roster/BasicInfo.tsx:320-370` 社保费用显示和编辑 +- `backend/src/services/contract.service.ts:205-206` 创建员工时社保基数设置 +- `frontend/src/lib/api-services.ts:510-520` socialInsuranceApi +- `backend/src/routes/social.routes.ts` 社保配置查询 + +**优化方案**: +1. 花名册社保费用计算改为读取社保模块中各险种的独立基数和比例 +2. 养老保险用养老基数、医疗保险用医疗基数,分别计算后汇总 +3. 确保两个模块的计算逻辑统一 + +--- + +### 问题20:合同附件PDF/Word不支持在线查看,且无法删除传错的附件 + +**模块**:花名册 - 劳动合同 +**优先级**:P0 +**状态**:待修复 + +**现状描述**: +劳务合同附件上传了 PDF 后不可以查看,显示没有插件;Word 也不支持在线查看,只有图片格式可以查看。且附件上传之后传错了无法删除,没有删除按钮。 + +**代码核查结果**: +- `ContractInfo.tsx:391-455` 附件预览弹窗实现: + - **图片**:`` 在线预览 ✅(`:432`) + - **PDF**:`` 在线预览 ✅(`:434`)——已支持,非完全缺失 + - **Word/其他**:显示"此文件格式不支持在线预览",提供下载 ❌(`:436-449`) +- 附件上传支持格式:`.pdf, .jpg, .jpeg, .png, .heic, .gif, .bmp, .webp, .doc, .docx, .xls, .xlsx, .tiff, .tif`(`:36,106`) +- **新建合同时**的附件可删除(`:258`)✅ +- **已保存合同的附件无删除按钮**——只有下载按钮(`:335-358`)和补充上传按钮(`:363`)❌ +- 附件以 base64 data URL 存储在 `attachmentUrl` 字段中,预览时转为 blob URL + +**涉及文件**: +- `frontend/src/pages/roster/ContractInfo.tsx:16-70` 附件上传逻辑 +- `frontend/src/pages/roster/ContractInfo.tsx:258` 新建时删除附件按钮 +- `frontend/src/pages/roster/ContractInfo.tsx:310-370` 已保存合同附件展示(无删除) +- `frontend/src/pages/roster/ContractInfo.tsx:391-455` 附件预览弹窗 + +**优化方案**: +1. Word 预览:使用 `mammoth.js` 转换为 HTML 在线预览,或提示下载查看 +2. 已保存合同的附件增加删除按钮,删除时二次确认 +3. 后端增加附件删除接口,更新 `attachmentUrl` 字段 + +--- + +### 问题21:用工办理与花名册添加员工功能重复 + +**模块**:花名册 / 用工办理 +**优先级**:P2 +**状态**:待优化 + +**现状描述**: +花名册可以添加员工,用工办理也可以录入员工,两个模块添加员工有什么区别不清楚。如果都可以添加没有必要,最好固定在一个模块。 + +**代码核查结果**: +- `Roster.tsx` 有 `AddEmployeeModal`(`modals.tsx:486-767`)直接创建员工 +- `WorkProcess.tsx:56-66` 的 `HIRE` 流程类型也创建员工,字段为 `name`、`department`、`idCardNumber` 等 text 输入 +- `WorkProcess.tsx:67-70` 的 `ONBOARD` 流程使用 `employee-select` 选择已有员工 +- 两个入口都调用 `createEmployee`,写入同一张表 + +**涉及文件**: +- `frontend/src/pages/Roster.tsx:70-90` 花名册状态和模态框 +- `frontend/src/pages/roster/modals.tsx:486-767` AddEmployeeModal +- `frontend/src/pages/WorkProcess.tsx:56-70` HIRE/ONBOARD 流程定义 +- `backend/src/routes/employee.routes.ts:99-126` 创建员工路由 +- `backend/src/services/contract.service.ts:193-272` createEmployee + +**优化方案**: +1. 统一员工添加入口为「用工办理 → 入职办理」,包含完整入职流程 +2. 花名册保留「查看」和「编辑」功能,移除独立添加入口 +3. 或在花名册添加员工时引导跳转到用工办理的入职流程 + +--- + +### 问题22:用工办理录入中途切换窗口丢失已填信息 + +**模块**:用工办理 +**优先级**:P1 +**状态**:待修复 + +**现状描述**: +在用工办理里录入员工,录到身份证号处,点开别的文件想粘贴一下,再回去,刚才录入的页面就退出了,需要重新打开重新录前面的信息。 + +**代码核查结果**: +- `frontend/src/components/ui/Modal.tsx:36` 遮罩层 `onClick={onClose}`——**点击遮罩层会关闭弹窗** +- 无 `closeOnOverlayClick={false}` 配置选项 +- 表单数据未持久化到 `sessionStorage` +- `AddEmployeeModal`(`modals.tsx:641`)使用了 `useUnsavedChanges(isDirty)` 但仅提示,不阻止关闭 +- `WorkProcess.tsx:204-205` 录入弹窗也使用 `div` + `onClick={onClose}` 模式 + +**涉及文件**: +- `frontend/src/components/ui/Modal.tsx:33-56` Modal 组件(遮罩层 onClick={onClose}) +- `frontend/src/pages/roster/modals.tsx:641-643` AddEmployeeModal useUnsavedChanges +- `frontend/src/pages/WorkProcess.tsx:204-205` 录入弹窗 + +**优化方案**: +1. 弹窗设置为 `closeOnOverlayClick={false}`,禁止点击遮罩层关闭 +2. 表单数据持久化到 `sessionStorage`,重新打开时恢复 +3. 关闭前增加「确认关闭?未保存的数据将丢失」提示 + +--- + +### 问题23:用工办理未按身份证号查重 + +**模块**:用工办理 +**优先级**:P1 +**状态**:待修复 + +**现状描述**: +在花名册录入一个人,在用工办理里录入了一个人但没录入身份证号,不显示重复,不知道是否用身份证查重。 + +**代码核查结果**: +- `createEmployee`(`contract.service.ts:193-272`)**无查重逻辑**——直接创建 +- 数据库依赖 `idCardHash` 唯一约束,重复时抛出 P2002 错误 +- `errorHandler.ts:27-31` P2002 错误返回通用"数据已存在,请勿重复操作"消息 +- 前端 `WorkProcess.tsx` 的 `HIRE` 流程类型使用 `text` 类型字段(`name`、`department` 等),**非 `employee-select`** +- `INCOME_CERT` 和 `LEAVING_CERT` 也使用 `text` 类型手动输入员工信息(`:107-114, :129-134`) +- `import.routes.ts:330-333` 导入时有身份证号查重,返回字段级错误信息 + +**涉及文件**: +- `backend/src/services/contract.service.ts:193-272` createEmployee(无查重) +- `backend/src/middleware/errorHandler.ts:27-31` P2002 错误处理 +- `frontend/src/pages/WorkProcess.tsx:56-66` HIRE 流程字段定义 +- `frontend/src/pages/WorkProcess.tsx:107-114,129-134` 证明开具字段(手动输入) +- `backend/src/routes/import.routes.ts:330-333` 导入查重(有字段级错误) + +**优化方案**: +1. 用工办理录入时根据姓名+手机号或身份证号查重 +2. 身份证号为空时用姓名+手机号组合查重 +3. 发现重复时提示「该员工已存在,是否查看/跳转」 + +--- + +## 九、证明开具模块 + +### 问题24:收入证明等应支持员工下拉选择,直接拉取数据 + +**模块**:用工办理 - 证明开具 +**优先级**:P1 +**状态**:待优化 + +**现状描述**: +开具收入证明或其他证明时,需要手动粘贴员工信息,应该有员工下拉选项直接拉取数据,避免开具非本公司员工的证明。 + +**代码核查结果**: +- `INCOME_CERT`(`WorkProcess.tsx:107-114`)字段为手动输入:`employeeName`(text)、`idCardNumber`(text)、`position`(text)、`monthlyIncome`(text) +- `LEAVING_CERT`(`:129-134`)同样为手动输入 +- **未使用 `employee-select` 类型**,不关联花名册 +- **但批量开具证明弹窗(`:537-590`)已有员工多选列表**——单条开具时却无下拉选择 +- `WorkProcess.tsx:746-809` 有 `EmployeeSelect` 组件实现,支持搜索和选择员工 +- `WorkProcess.tsx:298-306` 批量提交时从员工数据自动填充 `employeeName`、`idCardNumber`、`position` + +**涉及文件**: +- `frontend/src/pages/WorkProcess.tsx:107-114` INCOME_CERT 字段定义(手动输入) +- `frontend/src/pages/WorkProcess.tsx:129-134` LEAVING_CERT 字段定义(手动输入) +- `frontend/src/pages/WorkProcess.tsx:537-590` 批量开具证明弹窗(有员工选择) +- `frontend/src/pages/WorkProcess.tsx:746-809` EmployeeSelect 组件 +- `frontend/src/pages/WorkProcess.tsx:298-306` 批量提交自动填充字段 + +**优化方案**: +1. 证明开具表单增加员工下拉选择器,支持姓名/手机号搜索 +2. 选择员工后自动填充身份证号、入职日期、职务、月收入等字段 +3. 只允许选择本公司在职员工 + +--- + +## 十、培训记录模块 + +### 问题25:培训记录只能选择单个员工,不支持批量/按部门 + +**模块**:培训记录 +**优先级**:P1 +**状态**:待优化 + +**现状描述**: +添加培训记录只能选择一个员工,但实际培训可能是好几个员工一起,也可能是一个部门甚至整个公司。 + +**代码核查结果**: +- `TrainingRecords.tsx:211-220` 员工选择为 `
diff --git a/frontend/src/pages/Contracts.tsx b/frontend/src/pages/Contracts.tsx index cc6f88e..d6296c9 100644 --- a/frontend/src/pages/Contracts.tsx +++ b/frontend/src/pages/Contracts.tsx @@ -61,6 +61,7 @@ export default function Contracts() { queryClient.invalidateQueries({ queryKey: ['roster'] }) queryClient.invalidateQueries({ queryKey: ['dashboard'] }) queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) + localStorage.removeItem('add-employee-draft') setShowAddModal(false) }, }) @@ -219,6 +220,7 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: { const [form, setForm] = useState({ name: '', department: '', + position: '', hireDate: '', monthlySalary: '', gender: '男' as '男' | '女', @@ -239,6 +241,7 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: { const data: any = { name: form.name, department: form.department, + position: form.position || undefined, hireDate: new Date(form.hireDate).toISOString(), monthlySalary: form.monthlySalary, gender: form.gender, @@ -282,14 +285,19 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
+
+ + setForm({ ...form, position: e.target.value })} placeholder="如:前端工程师" /> +
setForm({ ...form, hireDate: e.target.value })} />
-
- - setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" /> -
+
+ +
+ + setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" />
diff --git a/frontend/src/pages/EmployeeBenefits.tsx b/frontend/src/pages/EmployeeBenefits.tsx index 7fe3789..19db575 100644 --- a/frontend/src/pages/EmployeeBenefits.tsx +++ b/frontend/src/pages/EmployeeBenefits.tsx @@ -3,7 +3,7 @@ import { toast } from 'sonner' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useConfirm } from '../hooks/useConfirm' import { Gift, Plus, Settings as SettingsIcon, X, Users } from 'lucide-react' -import { benefitApi, rosterApi } from '../lib/api-services' +import { benefitApi, employeeApi } from '../lib/api-services' import PageGuide from '../components/ui/PageGuide' import Card from '../components/ui/Card' import Button from '../components/ui/Button' @@ -72,10 +72,10 @@ export default function EmployeeBenefits() { enabled: tab === 'summary', }) - const { data: rosterData } = useQuery({ - queryKey: ['roster-for-benefit', ''], + const { data: rosterData } = useQuery({ + queryKey: ['employees-for-benefit'], queryFn: async () => { - return await rosterApi.list({ search: '', page: 1, pageSize: 200 } as any) as any + return await employeeApi.allLite({ status: 'ACTIVE' }) }, enabled: showEnrollModal, }) @@ -407,10 +407,10 @@ export default function EmployeeBenefits() { - e.status === 'ACTIVE').length || 0) && enrollEmployeeIds.length > 0} + 0} onChange={(e) => { if (e.target.checked) { - setEnrollEmployeeIds(rosterData?.items?.filter((emp: any) => emp.status === 'ACTIVE').map((emp: any) => emp.id) || []) + setEnrollEmployeeIds(rosterData?.map((emp: any) => emp.id) || []) } else { setEnrollEmployeeIds([]) } @@ -422,7 +422,7 @@ export default function EmployeeBenefits() { - {rosterData?.items?.filter((e: any) => e.status === 'ACTIVE').map((emp: any) => ( + {rosterData?.map((emp: any) => (
+ {verifyResult?.invalidItems?.length > 0 && ( +
+ {verifyResult.invalidItems.map((item: any) => ( +
+
+ + {item.description} +
+ {new Date(item.createdAt).toLocaleString('zh-CN')} +
+ ))} +
+ )} )} diff --git a/frontend/src/pages/Money.tsx b/frontend/src/pages/Money.tsx index c0e6f70..40c10c2 100644 --- a/frontend/src/pages/Money.tsx +++ b/frontend/src/pages/Money.tsx @@ -1,4 +1,5 @@ import { useState, lazy, Suspense } from 'react' +import { useSearchParams } from 'react-router-dom' import { Layers, Wallet, LayoutTemplate, Clock, Receipt, Loader2 } from 'lucide-react' const BatchManager = lazy(() => import('./money/BatchTab').then(m => ({ default: m.BatchManager }))) @@ -9,7 +10,9 @@ const PayslipManager = lazy(() => import('./money/PayslipTab').then(m => ({ defa type Tab = 'batch' | 'template' | 'overtime' | 'payslip' export default function Money() { - const [tab, setTab] = useState('batch') + const [searchParams] = useSearchParams() + const initialEmployeeId = searchParams.get('employeeId') || '' + const [tab, setTab] = useState(initialEmployeeId ? 'payslip' : 'batch') const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [ { key: 'batch', label: '发薪批次', icon: }, @@ -47,7 +50,7 @@ export default function Money() { {tab === 'batch' && } {tab === 'template' && } {tab === 'overtime' && } - {tab === 'payslip' && } + {tab === 'payslip' && } ) diff --git a/frontend/src/pages/Policies.tsx b/frontend/src/pages/Policies.tsx index 36b0c0d..282c26a 100644 --- a/frontend/src/pages/Policies.tsx +++ b/frontend/src/pages/Policies.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { usePageSize } from '../hooks/usePageSize' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import { FileText, Plus, ChevronRight, CheckCircle, Clock, X } from 'lucide-react' +import { FileText, Plus, ChevronRight, CheckCircle, Clock, X, Bell } from 'lucide-react' import { policiesApi } from '../lib/api-services' import Card from '../components/ui/Card' import Button from '../components/ui/Button' @@ -247,6 +247,8 @@ function CreatePolicyModal({ onClose, onSuccess }: { onClose: () => void; onSucc * 阅读签收统计组件 */ function ReadStats({ policyId }: { policyId: string }) { + const queryClient = useQueryClient() + const [showUnread, setShowUnread] = useState(false) const { data, isLoading } = useQuery({ queryKey: ['policy-read-stats', policyId], queryFn: async () => { @@ -254,6 +256,17 @@ function ReadStats({ policyId }: { policyId: string }) { }, }) + const remindMutation = useMutation({ + mutationFn: async (employeeIds?: string[]) => { + return await policiesApi.remind(policyId, employeeIds) + }, + onSuccess: (res: any) => { + toast.success(`已催办 ${res?.reminded || 0} 名未签收员工`) + queryClient.invalidateQueries({ queryKey: ['policy-read-stats', policyId] }) + }, + onError: () => toast.error('催办失败'), + }) + if (isLoading) return
加载阅读统计...
if (!data) return null @@ -271,8 +284,25 @@ function ReadStats({ policyId }: { policyId: string }) { {data.unreadCount > 0 && ( -
- {data.unreadCount} 人未签收 +
+ {data.unreadCount} 人未签收 + + +
+ )} + {showUnread && data.unreadEmployees && data.unreadEmployees.length > 0 && ( +
+ {data.unreadEmployees.map((r: any) => ( +
+ {r.employeeName} + {r.department} + 未签收 +
+ ))}
)} {data.records && data.records.length > 0 && ( diff --git a/frontend/src/pages/Roster.tsx b/frontend/src/pages/Roster.tsx index 9474fa2..14721a2 100644 --- a/frontend/src/pages/Roster.tsx +++ b/frontend/src/pages/Roster.tsx @@ -2,6 +2,7 @@ import { useState, useMemo, useEffect } from 'react' import { useSearchParams, useNavigate } from 'react-router-dom' import { usePageSize } from '../hooks/usePageSize' import { toast } from 'sonner' +import { toastError } from '../lib/errorToast' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useConfirm } from '../hooks/useConfirm' import { Users, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, Upload, Wallet, Download, Phone, MapPin, Search, Settings2 } from 'lucide-react' @@ -33,6 +34,8 @@ const ROSTER_COLUMNS = [ { key: 'contractStatus', label: '合同状态' }, { key: 'contractExpiry', label: '合同到期' }, { key: 'socialStatus', label: '社保状态' }, + { key: 'socialInsBase', label: '社保基数' }, + { key: 'socialInsAmount', label: '社保缴费' }, { key: 'records', label: '记录' }, ] as const @@ -127,8 +130,13 @@ export default function Roster() { queryClient.invalidateQueries({ queryKey: ['roster'] }) queryClient.invalidateQueries({ queryKey: ['dashboard'] }) queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) + localStorage.removeItem('add-employee-draft') setShowAddModal(false) + toast.success('员工已添加', { + action: { label: '前往用工办理', onClick: () => navigate('/work-process') }, + }) }, + onError: (err: any) => toastError(err, '创建失败'), }) const resignMutation = useMutation({ @@ -373,6 +381,9 @@ export default function Roster() { + @@ -504,6 +515,8 @@ export default function Roster() { {colVisible('contractStatus') && 合同状态} {colVisible('contractExpiry') && 合同到期} {colVisible('socialStatus') && 社保状态} + {colVisible('socialInsBase') && 社保基数} + {colVisible('socialInsAmount') && 社保缴费} {colVisible('records') && 记录} 操作 @@ -636,6 +649,26 @@ export default function Roster() { return {c.label} })()} } + {colVisible('socialInsBase') && + {e.socialInsBase ? e.socialInsBase.toLocaleString() : } + } + {colVisible('socialInsAmount') && + {(() => { + if (!e.socialInsCalc && !e.housingFundCalc) return + const socialEmp = e.socialInsCalc?.socialEmp || 0 + const socialOrg = e.socialInsCalc?.socialOrg || 0 + const housingEmp = e.housingFundCalc?.housingEmp || 0 + const housingOrg = e.housingFundCalc?.housingOrg || 0 + const totalEmp = socialEmp + housingEmp + const totalOrg = socialOrg + housingOrg + return ( +
+ 个人: {totalEmp.toFixed(2)} + 企业: {totalOrg.toFixed(2)} +
+ ) + })()} + } {colVisible('records') &&
{(() => { @@ -761,7 +794,7 @@ export default function Roster() {
setPage(p)} onPageSizeChange={() => setPage(1)} diff --git a/frontend/src/pages/Templates.tsx b/frontend/src/pages/Templates.tsx index d250ef9..dbdec7c 100644 --- a/frontend/src/pages/Templates.tsx +++ b/frontend/src/pages/Templates.tsx @@ -1,13 +1,14 @@ -import { useState } from 'react' +import { useState, useRef } from 'react' import { usePageSize } from '../hooks/usePageSize' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { FileText, Copy, X, Download, BookOpen, HelpCircle, Plus, Edit, Trash2, Building2 } from 'lucide-react' +import { FileText, Copy, X, Download, BookOpen, HelpCircle, Plus, Edit, Trash2, Building2, Upload } from 'lucide-react' import { toast } from 'sonner' import { templatesApi } 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 mammoth from 'mammoth' import Modal from '../components/ui/Modal' import EmptyState from '../components/ui/EmptyState' import Pagination from '../components/ui/Pagination' @@ -303,6 +304,7 @@ function SystemTemplates() { function EnterpriseTemplates() { const queryClient = useQueryClient() const confirm = useConfirm() + const fileInputRef = useRef(null) const [category, setCategory] = useState('') const pageSize = usePageSize() const [page, setPage] = useState(1) @@ -490,6 +492,31 @@ function EnterpriseTemplates() {
+
+ + { + const file = e.target.files?.[0] + if (!file) return + try { + const arrayBuffer = await file.arrayBuffer() + const result = await mammoth.convertToHtml({ arrayBuffer }) + setForm({ ...form, content: result.value }) + toast.success('文档导入成功') + } catch { + toast.error('文档解析失败,请确保为 .docx 格式') + } + e.target.value = '' + }} + /> + 支持 .docx 格式,导入后转为 HTML +