From c355a7d20836351bfef30bb37329efb37bf0afd3 Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Wed, 5 Aug 2026 20:26:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=2020260805=20=E7=B3=BB=E7=BB=9F=E4=BC=98?= =?UTF-8?q?=E5=8C=96=20-=20=E8=BA=AB=E4=BB=BD=E8=AF=81=E5=A4=8D=E5=88=B6fa?= =?UTF-8?q?llback/=E8=96=AA=E7=A8=8E=E6=97=A5=E6=9C=9F=E7=AD=9B=E9=80=89/?= =?UTF-8?q?=E7=A4=BE=E4=BF=9D=E7=89=88=E6=9C=AC=E4=BF=AE=E5=A4=8D/?= =?UTF-8?q?=E8=AF=81=E6=8D=AE=E9=93=BE=E5=AF=BC=E5=87=BA/=E8=BF=9D?= =?UTF-8?q?=E7=BA=AA=E8=AF=81=E6=98=8E/=E5=8C=BB=E7=96=97=E6=9C=9F?= =?UTF-8?q?=E6=94=BF=E7=AD=96/=E7=BB=A9=E6=95=88=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E8=AF=84=E7=BA=A7/=E5=90=88=E5=90=8C=E4=BD=9C=E5=BA=9F/?= =?UTF-8?q?=E5=B8=AE=E5=8A=A9=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/prisma/schema.prisma | 16 + backend/src/routes/employee.routes.ts | 28 +- backend/src/routes/payroll2.routes.ts | 6 +- backend/src/routes/roster.routes.ts | 91 ++++- backend/src/routes/settings.routes.ts | 98 +++++ backend/src/routes/social.routes.ts | 8 +- backend/src/routes/work-process.routes.ts | 18 + docs/20260805-优化.md | 352 ++++++++++++++++++ frontend/src/components/HelpModal.tsx | 137 ++----- frontend/src/components/layout/SidebarNav.tsx | 2 +- frontend/src/lib/api-services.ts | 9 + frontend/src/lib/clipboard.ts | 37 ++ frontend/src/pages/Roster.tsx | 3 +- frontend/src/pages/Settings.tsx | 189 +++++++++- frontend/src/pages/SocialInsurance.tsx | 8 + frontend/src/pages/WorkProcess.tsx | 4 +- frontend/src/pages/money/BatchTab.tsx | 22 +- frontend/src/pages/roster/BasicInfo.tsx | 5 +- frontend/src/pages/roster/ContractInfo.tsx | 116 +++++- .../src/pages/roster/DisciplinaryInfo.tsx | 33 +- .../src/pages/roster/DisciplinaryRecords.tsx | 31 +- frontend/src/pages/roster/PerformanceInfo.tsx | 30 +- .../src/pages/roster/PerformanceRecords.tsx | 31 +- .../pages/tools/MedicalPeriodCalculator.tsx | 168 ++++----- 24 files changed, 1185 insertions(+), 257 deletions(-) create mode 100644 docs/20260805-优化.md create mode 100644 frontend/src/lib/clipboard.ts diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b74315e..cbee6d1 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -195,6 +195,7 @@ model Organization { benefitPlans EmployeeBenefitPlan[] benefitEnrollments EmployeeBenefitEnrollment[] eSignRecords ESignRecord[] + medicalPeriodPolicies MedicalPeriodPolicy[] } model User { @@ -500,6 +501,21 @@ model OvertimeConfig { updatedAt DateTime @updatedAt } +model MedicalPeriodPolicy { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + region String // 地区名称,如"全国"、"上海"、"广东" + legalBasis String // 法律依据 + rules Json // 分档规则: [{ maxYears: 5, months: 3, cycleMonths: 6 }, ...] + isDefault Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([orgId, region]) + @@index([orgId]) +} + model NotificationLog { id String @id @default(cuid()) orgId String diff --git a/backend/src/routes/employee.routes.ts b/backend/src/routes/employee.routes.ts index de7a919..1d3614a 100644 --- a/backend/src/routes/employee.routes.ts +++ b/backend/src/routes/employee.routes.ts @@ -284,9 +284,33 @@ router.delete('/contracts/:contractId', authMiddleware, async (req: AuthRequest, if (!contract) { return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '合同不存在' } }) } - await prisma.laborContract.delete({ where: { id: req.params.contractId } }) + // 作废处理:设置结束日期为当前时间,保留记录但不物理删除 + await prisma.laborContract.update({ + where: { id: req.params.contractId }, + data: { endDate: new Date() }, + }) const emp = await prisma.employee.findFirst({ where: { id: contract.employeeId }, select: { name: true } }) - await auditLog(req, 'DELETE_CONTRACT', 'CONTRACT', req.params.contractId, { employeeName: emp?.name || '', employeeId: contract.employeeId, contractType: contract.contractType, startDate: contract.startDate, endDate: contract.endDate }) + await auditLog(req, 'VOID_CONTRACT', 'CONTRACT', req.params.contractId, { employeeName: emp?.name || '', employeeId: contract.employeeId, contractType: contract.contractType, startDate: contract.startDate, endDate: contract.endDate }) + res.json({ success: true }) + } catch (err) { + next(err) + } +}) + +// 补充上传合同附件 +router.patch('/contracts/:contractId/attachment', authMiddleware, async (req: AuthRequest, res, next) => { + try { + const contract = await prisma.laborContract.findFirst({ + where: { id: req.params.contractId, orgId: req.user!.orgId }, + }) + if (!contract) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '合同不存在' } }) + } + const { attachmentUrl } = req.body as { attachmentUrl: string } + await prisma.laborContract.update({ + where: { id: req.params.contractId }, + data: { attachmentUrl: attachmentUrl || null }, + }) res.json({ success: true }) } catch (err) { next(err) diff --git a/backend/src/routes/payroll2.routes.ts b/backend/src/routes/payroll2.routes.ts index 8ad8fd0..447eb29 100644 --- a/backend/src/routes/payroll2.routes.ts +++ b/backend/src/routes/payroll2.routes.ts @@ -150,7 +150,7 @@ router.get('/batches/archived/list', async (req: AuthRequest, res: Response, nex // 获取批次列表 router.get('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => { try { - const { month, monthFrom, monthTo, status, type } = req.query + const { month, monthFrom, monthTo, status, type, dateFrom, dateTo } = req.query const batches = await prisma.payrollBatch.findMany({ where: { orgId: req.user!.orgId, @@ -159,8 +159,10 @@ router.get('/batches', async (req: AuthRequest, res: Response, next: NextFunctio ...(monthTo ? { month: { lte: String(monthTo) } } : {}), ...(status ? { status: String(status) as any } : {}), ...(type ? { type: String(type) as any } : {}), + ...(dateFrom ? { createdAt: { gte: new Date(String(dateFrom)) } } : {}), + ...(dateTo ? { createdAt: { lte: new Date(String(dateTo) + 'T23:59:59') } } : {}), }, - orderBy: [{ month: 'desc' }, { batchNo: 'asc' }], + orderBy: [{ createdAt: 'desc' }, { month: 'desc' }, { batchNo: 'asc' }], }) res.json({ success: true, data: batches }) } catch (err) { diff --git a/backend/src/routes/roster.routes.ts b/backend/src/routes/roster.routes.ts index 11a1570..c48beec 100644 --- a/backend/src/routes/roster.routes.ts +++ b/backend/src/routes/roster.routes.ts @@ -18,6 +18,16 @@ function safeDecrypt(encrypted: string): number { } } +function safeDecryptStr(encrypted: string | null): string | null { + if (!encrypted) return null + try { + if (!encrypted.includes(':')) return encrypted + return decrypt(encrypted) + } catch { + return encrypted + } +} + // ========== 花名册聚合 API ========== // 获取部门列表(去重) @@ -161,7 +171,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => { gender: e.gender, phone: e.phone, idCardMasked, - idCardNumber: e.idCardNumber, + idCardNumber: safeDecryptStr(e.idCardNumber), monthlySalary: safeDecrypt(e.monthlySalary), isPregnant: e.isPregnant, isInMedicalPeriod: e.isInMedicalPeriod, @@ -199,16 +209,11 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => { result = result.filter((e) => e.contractStatus === contractStatus) } - // 身份证号后N位搜索:在内存中过滤(解密完整身份证号后匹配) + // 身份证号后N位搜索:在内存中过滤(idCardNumber 已解密为明文) if (isIdCardSearch) { result = result.filter((e: any) => { if (!e.idCardNumber) return false - try { - const fullIdCard = decrypt(e.idCardNumber) - return fullIdCard.endsWith(search!) - } catch { - return false - } + return String(e.idCardNumber).endsWith(search!) }) } @@ -312,7 +317,7 @@ router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) = status: dynamicStatus, monthlySalary: safeDecrypt(monthlySalary), bankAccount: bankAccount ? safeDecrypt(bankAccount).toString() : null, - idCardNumber: idCardNumber ? safeDecrypt(idCardNumber).toString() : null, + idCardNumber: safeDecryptStr(idCardNumber), monthlyProcessRecords, }, }) @@ -736,13 +741,20 @@ router.get('/:id/evidence-chain/export', authMiddleware, async (req: AuthRequest wsRisk.getRow(1).font = { bold: true } risks.forEach((r, i) => wsRisk.addRow({ no: i + 1, ...r })) - const encodedName = encodeURIComponent(empName) + const fullFileName = `${empName}_证据链.xlsx` + const encodedName = encodeURIComponent(fullFileName) + const asciiFallback = `evidence_chain_${employee.id.slice(-8)}.xlsx` res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') - res.setHeader('Content-Disposition', `attachment; filename="${encodedName}_证据链.xlsx"; filename*=UTF-8''${encodedName}_证据链.xlsx`) - await workbook.xlsx.write(res) - res.end() - } catch (err) { - next(err) + res.setHeader('Content-Disposition', `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodedName}`) + const buffer = await workbook.xlsx.writeBuffer() + res.send(Buffer.from(buffer)) + } catch (err: any) { + console.error('证据链导出失败:', err?.message || err) + if (!res.headersSent) { + res.status(500).json({ success: false, error: { code: 'EXPORT_FAILED', message: `导出失败:${err?.message || '服务器错误'}` } }) + } else { + res.end() + } } }) @@ -919,6 +931,55 @@ router.delete('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: } catch (err) { next(err) } }) +// 违纪确认证明导出 +router.get('/:employeeId/disciplinary/:recordId/certificate', authMiddleware, async (req: AuthRequest, res: Response, next) => { + try { + const record = await prisma.disciplinaryRecord.findFirst({ + where: { id: req.params.recordId, orgId: req.user!.orgId }, + include: { employee: true }, + }) + if (!record) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } }) + } + const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId } }) + const typeMap: Record = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' } + const actionMap: Record = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '解除劳动合同' } + const severityMap: Record = { WARNING: '警告', SERIOUS: '严重', SEVERE: '重度' } + + let idCard = '' + try { if (record.employee.idCardNumber) idCard = decrypt(record.employee.idCardNumber) } catch { idCard = record.employee.idCardNumber || '' } + + const content = `违纪确认证明 + +兹证明 ${record.employee.name}(身份证号:${idCard || '___'})系我单位员工,于 ${record.violationDate.toISOString().slice(0, 10)} 发生以下违纪行为: + +违纪类型:${typeMap[record.violationType] || record.violationType} +严重程度:${severityMap[record.severity] || record.severity} +违纪事实:${record.description} +处理结果:${actionMap[record.action] || record.action}${record.actionDetail ? `(${record.actionDetail})` : ''} + +${record.employeeAck ? `该员工已于 ${record.ackDate ? new Date(record.ackDate).toISOString().slice(0, 10) : '___'} 签字确认上述违纪事实及处理结果。${record.witness ? `见证人:${record.witness}。` : ''}` : '该员工尚未签字确认。'} + +特此证明。 + +${org?.name || ''} +${new Date().toLocaleDateString('zh-CN')}` + + const blob = Buffer.from('\ufeff' + content, 'utf8') + const certFileName = `${record.employee.name}_违纪确认证明.doc` + const encodedCertName = encodeURIComponent(certFileName) + const asciiCertFallback = `disciplinary_cert_${record.id.slice(-8)}.doc` + res.setHeader('Content-Type', 'application/msword;charset=utf-8') + res.setHeader('Content-Disposition', `attachment; filename="${asciiCertFallback}"; filename*=UTF-8''${encodedCertName}`) + res.send(blob) + } catch (err: any) { + console.error('违纪确认证明导出失败:', err?.message || err) + if (!res.headersSent) { + res.status(500).json({ success: false, error: { code: 'EXPORT_FAILED', message: `导出失败:${err?.message || '服务器错误'}` } }) + } + } +}) + // ========== 考勤记录 CRUD ========== router.get('/:employeeId/attendance', authMiddleware, async (req: AuthRequest, res, next) => { diff --git a/backend/src/routes/settings.routes.ts b/backend/src/routes/settings.routes.ts index 02649e8..7fba1ab 100644 --- a/backend/src/routes/settings.routes.ts +++ b/backend/src/routes/settings.routes.ts @@ -245,3 +245,101 @@ router.post('/retirement-policy/:id/confirm', requireAdmin, async (req: AuthRequ } }) +// ========== 医疗期政策配置 ========== + +const DEFAULT_POLICIES = [ + { + region: '全国', + legalBasis: '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)', + rules: [ + { maxYears: 5, months: 3, cycleMonths: 6 }, + { maxYears: 10, months: 6, cycleMonths: 12 }, + { maxYears: 15, months: 9, cycleMonths: 15 }, + { maxYears: 20, months: 12, cycleMonths: 18 }, + { maxYears: 999, months: 24, cycleMonths: 30 }, + ], + isDefault: true, + }, + { + region: '上海', + legalBasis: '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》', + rules: [ + { maxYears: 1, months: 3, cycleMonths: 6 }, + { maxYears: 4, months: 3, cycleMonths: 6 }, + { maxYears: 10, months: 6, cycleMonths: 12 }, + { maxYears: 999, months: 9, cycleMonths: 18 }, + ], + isDefault: false, + }, +] + +// 获取医疗期政策列表 +router.get('/medical-period/policies', async (req: AuthRequest, res, next) => { + try { + let policies = await prisma.medicalPeriodPolicy.findMany({ + where: { orgId: req.user!.orgId }, + orderBy: [{ isDefault: 'desc' }, { region: 'asc' }], + }) + if (policies.length === 0) { + policies = await prisma.$transaction( + DEFAULT_POLICIES.map(p => + prisma.medicalPeriodPolicy.create({ + data: { orgId: req.user!.orgId, ...p }, + }) + ) + ) + } + res.json({ success: true, data: policies }) + } catch (err) { + next(err) + } +}) + +// 新增/编辑医疗期政策 +const medicalPolicySchema = z.object({ + region: z.string().min(1, '地区名称不能为空'), + legalBasis: z.string().min(1, '法律依据不能为空'), + rules: z.array(z.object({ + maxYears: z.number().min(0), + months: z.number().min(1), + cycleMonths: z.number().min(1), + })).min(1, '至少需要一条分档规则'), + isDefault: z.boolean().default(false), +}) + +router.post('/medical-period/policies', requireAdmin, async (req: AuthRequest, res, next) => { + try { + const data = medicalPolicySchema.parse(req.body) + if (data.isDefault) { + await prisma.medicalPeriodPolicy.updateMany({ + where: { orgId: req.user!.orgId }, + data: { isDefault: false }, + }) + } + const policy = await prisma.medicalPeriodPolicy.upsert({ + where: { orgId_region: { orgId: req.user!.orgId, region: data.region } }, + update: { legalBasis: data.legalBasis, rules: data.rules, isDefault: data.isDefault }, + create: { orgId: req.user!.orgId, ...data }, + }) + res.json({ success: true, data: policy }) + } catch (err: any) { + if (err.issues) return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: err.issues[0]?.message } }) + next(err) + } +}) + +// 删除医疗期政策 +router.delete('/medical-period/policies/:id', requireAdmin, async (req: AuthRequest, res, next) => { + try { + const policy = await prisma.medicalPeriodPolicy.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId }, + }) + if (!policy) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '政策不存在' } }) + if (policy.isDefault) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '不能删除默认政策' } }) + await prisma.medicalPeriodPolicy.delete({ where: { id: req.params.id } }) + res.json({ success: true }) + } catch (err) { + next(err) + } +}) + diff --git a/backend/src/routes/social.routes.ts b/backend/src/routes/social.routes.ts index 6b67923..aeba1f7 100644 --- a/backend/src/routes/social.routes.ts +++ b/backend/src/routes/social.routes.ts @@ -9,7 +9,7 @@ const router = Router() router.use(authMiddleware) const socialConfigFields = { - city: z.string().optional(), + city: z.string().min(1), pensionOrg: z.number().optional(), pensionEmp: z.number().optional(), medicalOrg: z.number().optional(), @@ -26,7 +26,7 @@ const socialConfigFields = { } const housingConfigFields = { - city: z.string().optional(), + city: z.string().min(1), accountType: z.string().optional(), housingOrg: z.number().optional(), housingEmp: z.number().optional(), @@ -136,9 +136,9 @@ router.post('/config/versions', async (req: AuthRequest, res: Response, next: Ne return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有配置版本` }) } - // 将之前当前版本标记为失效 + // 将之前当前版本标记为失效(按城市过滤) const prevCurrent = await prisma.socialInsuranceConfig.findFirst({ - where: { orgId, isCurrent: true }, + where: { orgId, city: data.city, isCurrent: true }, }) if (prevCurrent) { // 计算上个版本的失效月份 = 新版本生效月份的前一个月 diff --git a/backend/src/routes/work-process.routes.ts b/backend/src/routes/work-process.routes.ts index 7f65cc7..32dcd24 100644 --- a/backend/src/routes/work-process.routes.ts +++ b/backend/src/routes/work-process.routes.ts @@ -185,6 +185,24 @@ router.post('/:id/approve', authMiddleware, async (req: AuthRequest, res: Respon } } + // 离职证明/收入证明审批通过后,如果有关联员工,创建电子签记录 + if (execResult.employeeId && (process.type === 'LEAVING_CERT' || process.type === 'INCOME_CERT')) { + const docTitle = process.type === 'LEAVING_CERT' ? '离职证明签署' : '收入证明签署' + await prisma.eSignRecord.create({ + data: { + orgId: req.user!.orgId, + employeeId: execResult.employeeId, + scene: process.type === 'LEAVING_CERT' ? 'RESIGNATION' : 'OTHER', + documentTitle: docTitle, + status: 'PENDING', + initiatedBy: req.user!.id, + createdBy: req.user!.id, + remark: '文书审批通过后自动发起', + expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + }, + }).catch(() => {}) + } + res.json({ success: true, data: updated }) } catch (err) { next(err) diff --git a/docs/20260805-优化.md b/docs/20260805-优化.md new file mode 100644 index 0000000..5d7ed54 --- /dev/null +++ b/docs/20260805-优化.md @@ -0,0 +1,352 @@ +# 20260805 优化需求清单 + +> 基于用户反馈整理,对照系统代码逐一分析问题根因及优化方案。 + +--- + +## 问题1:花名册身份证号复制后粘贴为乱码 + +**模块**:花名册 +**优先级**:P0 +**状态**:待修复 + +**现状描述**: +花名册列表和员工详情页均支持点击身份证号复制,但用户反馈复制后粘贴出来是乱码。 + +**问题分析**: +- 列表页 `Roster.tsx:522-526`:点击脱敏身份证号时调用 `navigator.clipboard.writeText(e.idCardNumber)` 复制完整身份证号 +- 详情页 `BasicInfo.tsx:200-213`:同样使用 `navigator.clipboard.writeText(profile.idCardNumber)` 复制 +- `navigator.clipboard.writeText` 在非 HTTPS 环境或部分浏览器下可能静默失败,clipboard API 返回的 Promise 可能被 reject +- 当前 `.catch(() => toast.error('复制失败'))` 仅提示失败,但用户可能看到"已复制"提示后实际粘贴为空或乱码 +- 可能原因:`idCardNumber` 字段经过加密存储,解密后的值可能包含不可见字符或编码问题 + +**涉及文件**: +- `frontend/src/pages/Roster.tsx:522-526` +- `frontend/src/pages/roster/BasicInfo.tsx:200-213` + +**优化方案**: +1. 检查 `idCardNumber` 字段是否经过 `decrypt()` 解密,确认复制的是明文而非加密后的乱码 +2. 增加 fallback 方案:当 `navigator.clipboard` 不可用时,使用 `document.execCommand('copy')` + 隐藏 textarea 兜底 +3. 复制后增加验证:读取 clipboard 内容验证是否与原始值一致 +4. 确认后端返回的 `idCardNumber` 已正确解密为明文 + +--- + +## 问题2:薪税管理筛选条件需精确到年月日,且每笔工资需有创建时间 + +**模块**:薪税管理 +**优先级**:P1 +**状态**:待优化 + +**现状描述**: +薪税管理中筛选条件仅支持按月(YYYY-MM)筛选,无法精确到具体日期。同时发薪批次列表未显示创建时间,难以区分同月多笔工资。 + +**问题分析**: +- `BatchTab.tsx:101-103`:筛选条件为 `month`(YYYY-MM)、`monthFrom`、`monthTo`,均为月份级别 +- 后端 `payroll2.routes.ts:151-168`:查询参数 `month`、`monthFrom`、`monthTo` 也只支持月份级别 +- `PayrollBatch` schema 有 `createdAt` 字段(`schema.prisma:710`),但前端列表未展示 +- 同月可创建多个批次(`batchNo` 区分),但用户无法直观看出创建先后顺序 + +**涉及文件**: +- `frontend/src/pages/money/BatchTab.tsx:101-103, 220-240` +- `backend/src/routes/payroll2.routes.ts:151-168` +- `backend/prisma/schema.prisma:691-719`(PayrollBatch model) + +**优化方案**: +1. 批次列表增加「创建时间」列,显示 `createdAt`(格式:YYYY-MM-DD HH:mm) +2. 筛选条件增加日期范围选择器(`dateFrom` / `dateTo`),后端按 `createdAt` 过滤 +3. 列表默认按 `createdAt desc` 排序(当前按 `month desc, batchNo asc`) +4. 批次详情中每条工资条目也可展示创建/修改时间 + +--- + +## 问题3:社保公积金无法创建和保存新的政策比例 + +**模块**:社保公积金 +**优先级**:P0 +**状态**:待修复 + +**现状描述**: +用户在社保公积金页面创建新版本政策比例时无法保存成功。 + +**问题分析**: +- 前端 `SocialInsurance.tsx:183-201`:`createVersionMutation` 和 `createHousingVersionMutation` 调用后端 API +- 后端 `social.routes.ts:126-168`:创建社保配置版本时,检查同一城市同一生效月份是否已有版本,如有则返回 400 错误 +- 后端 `social.routes.ts:509-549`:创建公积金配置版本同样检查重复 +- 可能原因: + 1. 前端 `newVersion.city` 默认为 `'北京'`,但后端 `socialConfigFields` 中 `city` 为 `optional`,若前端未传或传空可能导致 `where` 条件匹配到 `city: null` 的已有记录 + 2. 后端 `prevCurrent` 查询 `where: { orgId, isCurrent: true }` 未按城市过滤(社保),可能将其他城市的当前版本也标记为失效 + 3. 前端 `createVersionMutation` 的 `onSuccess` 未显示错误详情,`onError` 未定义,用户可能看不到错误信息 + 4. `z.object` 校验可能因前端传入的字段类型不匹配(如 `number` 传为 `string`)而静默失败 + +**涉及文件**: +- `frontend/src/pages/SocialInsurance.tsx:183-201, 786-789` +- `backend/src/routes/social.routes.ts:11-26, 120-168, 503-549` +- `backend/prisma/schema.prisma:430-445`(SocialInsuranceConfig model) + +**优化方案**: +1. 后端 `prevCurrent` 查询增加 `city` 过滤条件,避免误将其他城市的版本标记失效 +2. 前端 `createVersionMutation` 和 `createHousingVersionMutation` 增加 `onError` 回调,显示后端返回的错误信息 +3. 前端提交前校验必填字段(城市、生效月份、各比例),确保类型正确 +4. 后端 `createVersionSchema` 的 `city` 字段改为 `z.string().min(1)` 必填,避免 null 匹配问题 +5. 增加 try-catch 日志输出,方便排查具体失败原因 + +--- + +## 问题4:证据链无法导出,导出证据链显示导出失败 + +**模块**:证据链 +**优先级**:P0 +**状态**:待修复 + +**现状描述**: +员工档案 → 证据链页面,点击「导出证据链」按钮提示"导出失败"。 + +**问题分析**: +- 前端 `EvidenceChain.tsx:47-63`:`handleExport` 使用 `fetch` 请求 `/api/v1/roster/${employeeId}/evidence-chain/export`,获取 blob 后下载 +- 后端 `roster.routes.ts:582-747`:使用 `ExcelJS` 生成 xlsx 文件并返回 +- 可能原因: + 1. 后端 `ExcelJS` 依赖未在服务器安装(`package.json` 中有 `exceljs: ^4.4.0`,但服务器可能未执行 `npm install`) + 2. `workbook.xlsx.write(res)` 写入流可能因 res 已设置 header 但写入失败而报错 + 3. 前端 `fetch` 请求未携带 `Content-Type: application/json`,但后端返回的是二进制流,`res.blob()` 可能解析失败 + 4. 服务器内存不足导致 ExcelJS 生成大文件失败 + 5. Nginx 代理可能对大响应体有超时或大小限制 + +**涉及文件**: +- `frontend/src/pages/roster/EvidenceChain.tsx:47-63` +- `backend/src/routes/roster.routes.ts:582-747` +- `backend/package.json:22`(exceljs 依赖) + +**优化方案**: +1. 确认服务器已安装 exceljs 依赖(`npm ls exceljs`) +2. 后端增加错误日志:`catch (err) { console.error('证据链导出失败:', err); next(err) }` +3. 前端 `handleExport` 增加详细错误处理:读取 `res.text()` 获取后端错误信息 +4. 后端 `workbook.xlsx.write(res)` 改为 `workbook.xlsx.writeBuffer()` 然后 `res.send(buffer)`,避免流写入问题 +5. 检查 Nginx `proxy_buffer_size` 和 `proxy_read_timeout` 配置 + +--- + +## 问题5:用工办理中离职证明无法自主选择模板,导出为txt格式且格式混乱 + +**模块**:用工办理 +**优先级**:P0 +**状态**:待优化 + +**现状描述**: +用工办理中开具离职证明时只能使用系统默认模板,导出的证明是 txt 文档格式混乱,希望能自主选择模板且能直接电子签章后提供给员工。 + +**问题分析**: +- 前端 `WorkProcess.tsx:129-135`:`LEAVING_CERT` 表单已有 `enterpriseTemplateId` 字段(`enterprise-template` 类型),支持选择企业自定义模板 +- 后端 `work-process.service.ts:246-261`:`generateDocument` 函数已支持企业模板渲染(`formData.enterpriseTemplateId`) +- 但生成文件扩展名为 `.doc`(`work-process.service.ts:259, 289`),实际内容为纯文本,非真正的 Word 文档 +- `EnterpriseTemplateSelect` 组件(`WorkProcess.tsx:722-744`)已实现模板选择下拉框,但用户可能未创建企业模板 +- 导出的文书存储在 `workProcess.documents` 字段(JSON 数组),未关联电子签章流程 + +**涉及文件**: +- `frontend/src/pages/WorkProcess.tsx:129-135, 722-744` +- `backend/src/services/work-process.service.ts:245-290` +- `backend/src/routes/work-process.routes.ts:136-192` +- `backend/src/routes/enterprise-template.routes.ts` + +**优化方案**: +1. **导出格式优化**:将纯文本 `.doc` 改为生成真正的 Word 文档(使用 `docx` 库)或 PDF 格式 +2. **模板选择增强**:在离职证明表单中增加模板预览功能,选择模板后可实时预览渲染效果 +3. **电子签章集成**:审批通过后自动创建电子签署记录(类似入职流程 `work-process.routes.ts:168-186`),场景为 `RESIGNATION` +4. **文书下载优化**:前端增加文书下载按钮,支持直接下载 PDF/Word 格式 +5. **模板提示**:当无企业模板时,增加快捷跳转链接到「模板库 → 企业文本库」创建 + +--- + +## 问题6:用工办理中多个模块功能重复 + +**模块**:用工办理 +**优先级**:P2 +**状态**:待优化 + +**现状描述**: +用工办理中多个流程类型功能重复,都是录入员工信息和合同时间,希望合并精简。 + +**问题分析**: +- `work-process.service.ts:8-22`:共定义 13 类流程 +- 功能重复的流程: + - `HIRE`(员工录用)和 `ONBOARD`(员工入职):都涉及录入员工信息和创建合同 + - `CUSTOM_CONTRACT`(自定义合同签署)和 `CHANGE`(合同变更)和 `RENEW`(合同续签):都是合同相关操作 + - `TERMINATE`(合同终止)和 `RESCIND`(合同解除):都是结束劳动关系 + - `INCOME_CERT`(收入证明)和 `LEAVING_CERT`(离职证明):都是开具证明文书 +- 前端 `WorkProcess.tsx` 的 `FORM_FIELDS` 配置中多个流程字段高度重叠(employeeName、idCardNumber、startDate、endDate 等) + +**涉及文件**: +- `backend/src/services/work-process.service.ts:8-22` +- `frontend/src/pages/WorkProcess.tsx`(FORM_FIELDS 配置) + +**优化方案**: +1. **合并入离职类**:将 `HIRE` 和 `ONBOARD` 合并为「入职办理」,区分"新员工入职"和"录用+入职一步完成"两种模式 +2. **合并合同类**:将 `CUSTOM_CONTRACT`、`CHANGE`、`RENEW` 合并为「合同签署/变更」,通过子类型区分 +3. **合并解聘类**:将 `TERMINATE` 和 `RESCIND` 合并为「解除/终止合同」,通过原因字段区分 +4. **合并证明类**:将 `INCOME_CERT` 和 `LEAVING_CERT` 合并为「开具证明」,通过证明类型切换模板 +5. **保留独立流程**:`CONFIRM`(转正)、`SUSPEND`(中止)、`FLEXIBLE`(灵活用工)、`INFO_SUBMIT`(信息变更)保持独立 +6. 合并后流程类型从 13 个精简为约 8 个,减少用户选择困难 + +--- + +## 问题7:违纪记录员工签字确认后企业端需可下载违纪确认证明 + +**模块**:违纪记录 +**优先级**:P0 +**状态**:待新增 + +**现状描述**: +员工在员工端签字确认违纪记录后,企业端没有可下载的违纪确认证明文件。 + +**问题分析**: +- 前端 `DisciplinaryInfo.tsx`:仅展示违纪记录列表和新增表单,无下载/导出功能 +- 后端 `roster.routes.ts:478-490`:证据链中包含违纪记录信息,但无单独的违纪确认证明导出接口 +- `DisciplinaryRecord` schema(`schema.prisma:565-577`)有 `employeeAck`、`ackDate`、`ackMethod`、`witness`、`attachmentUrl` 字段,但无独立的证明生成功能 +- 培训记录已有签收单导出的先例可参考 + +**涉及文件**: +- `frontend/src/pages/roster/DisciplinaryInfo.tsx` +- `frontend/src/pages/roster/PerformanceRecords.tsx`(同样需要下载功能) +- `backend/src/routes/roster.routes.ts`(需新增导出接口) +- `backend/prisma/schema.prisma:565-577`(DisciplinaryRecord model) + +**优化方案**: +1. 后端新增 `GET /roster/:employeeId/disciplinary/:recordId/certificate` 接口,生成违纪确认证明 PDF +2. 证明内容包含:企业名称、员工姓名、身份证号、违纪事实、处理结果、签字确认状态、确认日期、见证人 +3. 前端 `DisciplinaryInfo.tsx` 在已签字的记录上增加「下载确认证明」按钮 +4. 同步为绩效考核记录增加类似的确认证明下载功能 +5. 证明格式使用 PDF(使用 `pdfkit` 或 `puppeteer` 生成) + +--- + +## 问题8:医疗期计算只有全国和上海两个地区政策 + +**模块**:医疗期计算器 +**优先级**:P2 +**状态**:待优化 + +**现状描述**: +医疗期计算器仅支持"全国(通用规定)"和"上海(特殊规定)"两个地区选项,其他有特殊政策的地区无法选择。 + +**问题分析**: +- 前端 `MedicalPeriodCalculator.tsx:42-86`:`calculateMedicalPeriod` 函数硬编码了 `region: 'shanghai' | 'national'` 两种逻辑 +- 地区选择为固定下拉框(`MedicalPeriodCalculator.tsx:146-153`),只有两个选项 +- 后端 `special-status.service.ts:68-76`:`calculateMedicalMonths` 函数也仅按全国通用标准计算,未区分地区 +- 各地特殊政策举例: + - 广东:按实际工作年限和本单位工作年限分档 + - 北京:与全国规定一致但有补充细则 + - 江苏、浙江等省份有各自的地方规定 + +**涉及文件**: +- `frontend/src/pages/tools/MedicalPeriodCalculator.tsx:29-107, 146-153` +- `backend/src/services/special-status.service.ts:68-76` + +**优化方案**: +1. 将地区政策配置改为数据驱动,支持动态添加地区规则 +2. 新增 `medicalPeriodPolicy` 配置表或 JSON 配置,存储各地政策分档规则 +3. 前端地区选择改为可搜索下拉框,支持从配置中动态加载 +4. 管理员可在系统设置中添加自定义地区政策(工龄分档 → 医疗期月数 → 累计周期月数) +5. 预置全国通用、上海、广东、北京等常见地区政策 +6. 后端 `calculateMedicalMonths` 函数同步支持按地区查询配置 + +--- + +## 问题9:绩效考核需区分月度/年度考核,得分与等级应关联 + +**模块**:绩效考核 +**优先级**:P0 +**状态**:待优化 + +**现状描述**: +1. 绩效考核无法区分月度考核与年度考核 +2. 录入的得分和等级二者无关联,应按得分自动分等级 + +**问题分析**: +- `PerformanceRecord` schema(`schema.prisma:624-643`):`period` 字段为自由文本(`YYYY-MM` 或 `YYYY-Q1`),无考核类型字段 +- `score`(Float)和 `grade`(String,A/B/C/D)是独立字段,前端表单分别输入,无联动逻辑 +- `result`(EXCELLENT/QUALIFIED/NEED_IMPROVE/UNQUALIFIED)也与 `score` 和 `grade` 无关联 +- 前端 `PerformanceInfo.tsx:38-48`:考核周期为自由输入框,得分和等级分别独立选择 +- 前端 `PerformanceRecords.tsx:212-228`:考核周期使用 `type="month"` 选择器,仅支持月度 + +**涉及文件**: +- `frontend/src/pages/roster/PerformanceInfo.tsx:14, 38-48` +- `frontend/src/pages/roster/PerformanceRecords.tsx:181-270` +- `backend/prisma/schema.prisma:624-643`(PerformanceRecord model) +- `backend/src/routes/roster.routes.ts:1064-1097` + +**优化方案**: +1. **新增考核类型字段**:`PerformanceRecord` 增加 `periodType` 字段(`MONTHLY`/`QUARTERLY`/`YEARLY`),前端表单增加类型选择 +2. **考核周期选择优化**:根据 `periodType` 动态切换输入方式(月度→ month 选择器,季度→ Q1/Q2/Q3/Q4 选择,年度→ year 选择器) +3. **得分等级自动关联**: + - 前端输入得分后自动计算等级和结果: + - 90-100 → A(优秀 EXCELLENT) + - 80-89 → B(合格 QUALIFIED) + - 60-79 → C(需改进 NEED_IMPROVE) + - 0-59 → D(不胜任 UNQUALIFIED) + - 等级和结果字段变为只读,由得分自动填充(可手动覆盖,覆盖后标记为"手动调整") +4. **后端校验**:保存时校验得分与等级的匹配性,若不一致记录日志 +5. **列表展示**:绩效考核列表页增加考核类型筛选(月度/季度/年度) + +--- + +## 问题10:花名册劳动合同无法下载,且不应能删除 + +**模块**:花名册 → 劳动合同 +**优先级**:P0 +**状态**:待修复 + +**现状描述**: +1. 员工花名册中的劳动合同附件无法下载,点击附件和下载按钮都无反应 +2. 劳动合同作为重要资料可以修改或覆盖,但不应该能删除 + +**问题分析**: +- 前端 `ContractInfo.tsx:258-289`:合同附件展示区域尝试解析 `c.attachmentUrl`(JSON 或 data URL),使用 `` 下载 +- 附件以 base64 data URL 形式存储在数据库中,`` 标签的 `download` 属性对 data URL 在某些浏览器下不生效 +- 下载无反应的可能原因: + 1. data URL 过长,浏览器阻止下载 + 2. `attachmentUrl` 字段存储的是 JSON 字符串,解析失败时回退逻辑可能未正确处理 + 3. `` 标签点击事件被外层 ` + + + {isLoading ? ( +
加载中...
+ ) : policies.length === 0 ? ( +
暂无政策
+ ) : ( +
+ {policies.map((p: any) => ( +
+
+
+ {p.region} + {p.isDefault && 默认} +
+
+ + {!p.isDefault && ( + + )} +
+
+
{p.legalBasis}
+
+ + + + + + + + + + {p.rules.map((rule: any, idx: number) => { + const prevMax = idx > 0 ? p.rules[idx - 1].maxYears : 0 + const isLast = idx === p.rules.length - 1 + return ( + + + + + + ) + })} + +
工作年限医疗期累计周期
{isLast ? `${prevMax} 年以上` : `${prevMax}-${rule.maxYears} 年`}{rule.months} 个月{rule.cycleMonths} 个月
+
+
+ ))} +
+ )} + + + {showForm && ( + saveMut.mutate(data)} + onClose={() => { setShowForm(false); setEditPolicy(null) }} + /> + )} + + ) +} + +function MedicalPolicyForm({ policy, onSave, onClose }: { policy: any; onSave: (data: any) => void; onClose: () => void }) { + const [region, setRegion] = useState(policy?.region || '') + const [legalBasis, setLegalBasis] = useState(policy?.legalBasis || '') + const [isDefault, setIsDefault] = useState(policy?.isDefault || false) + const [rules, setRules] = useState( + policy?.rules?.length ? policy.rules : [{ maxYears: 5, months: 3, cycleMonths: 6 }] + ) + + const addRule = () => setRules([...rules, { maxYears: 10, months: 6, cycleMonths: 12 }]) + const updateRule = (idx: number, field: string, value: number) => { + setRules(rules.map((r, i) => i === idx ? { ...r, [field]: value } : r)) + } + const removeRule = (idx: number) => { + if (rules.length <= 1) return + setRules(rules.filter((_, i) => i !== idx)) + } + + const handleSave = () => { + if (!region.trim()) { toast.error('请输入地区名称'); return } + if (!legalBasis.trim()) { toast.error('请输入法律依据'); return } + const sortedRules = [...rules].sort((a, b) => a.maxYears - b.maxYears) + onSave({ region: region.trim(), legalBasis: legalBasis.trim(), rules: sortedRules, isDefault }) + } + + return ( +
+
e.stopPropagation()}> +
+

{policy ? '编辑政策' : '新增政策'}

+ +
+
+
+ + setRegion(e.target.value)} placeholder="如:广东" disabled={!!policy?.isDefault} /> +
+
+ + setLegalBasis(e.target.value)} placeholder="如:《广东省...》" /> +
+
+ +
+ {rules.map((rule, idx) => ( +
+ 工龄 < + updateRule(idx, 'maxYears', parseInt(e.target.value) || 0)} className="w-16 px-2 py-1 text-xs border rounded" /> + 年 → + updateRule(idx, 'months', parseInt(e.target.value) || 0)} className="w-16 px-2 py-1 text-xs border rounded" /> + 个月,周期 + updateRule(idx, 'cycleMonths', parseInt(e.target.value) || 0)} className="w-16 px-2 py-1 text-xs border rounded" /> + 个月 + {rules.length > 1 && ( + + )} +
+ ))} + +
+
+ +
+ + +
+
+
+
+ ) +} + diff --git a/frontend/src/pages/SocialInsurance.tsx b/frontend/src/pages/SocialInsurance.tsx index d634297..56c3f10 100644 --- a/frontend/src/pages/SocialInsurance.tsx +++ b/frontend/src/pages/SocialInsurance.tsx @@ -188,6 +188,10 @@ export default function SocialInsurance() { setShowNewVersion(false) toast.success('新版本已创建,旧版本已自动归档') }, + onError: (err: any) => { + const msg = err?.response?.data?.message || err?.message || '创建失败' + toast.error(msg) + }, }) const createHousingVersionMutation = useMutation({ @@ -198,6 +202,10 @@ export default function SocialInsurance() { setShowNewVersion(false) toast.success('公积金新版本已创建,旧版本已自动归档') }, + onError: (err: any) => { + const msg = err?.response?.data?.message || err?.message || '创建失败' + toast.error(msg) + }, }) const aiSuggestMut = useMutation({ diff --git a/frontend/src/pages/WorkProcess.tsx b/frontend/src/pages/WorkProcess.tsx index 2b5dfed..58badde 100644 --- a/frontend/src/pages/WorkProcess.tsx +++ b/frontend/src/pages/WorkProcess.tsx @@ -676,11 +676,11 @@ function DetailContent({ id, previewContent, onPreview, onSubmit, onCancel, onDe onPreview(data.id) return } - const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }) + const blob = new Blob(['\ufeff' + content], { type: 'application/msword;charset=utf-8' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url - a.download = `${doc.name}.txt` + a.download = doc.name.endsWith('.doc') ? doc.name : `${doc.name}.doc` a.click() URL.revokeObjectURL(url) }} diff --git a/frontend/src/pages/money/BatchTab.tsx b/frontend/src/pages/money/BatchTab.tsx index 7da2ab2..5ee0c67 100644 --- a/frontend/src/pages/money/BatchTab.tsx +++ b/frontend/src/pages/money/BatchTab.tsx @@ -101,6 +101,8 @@ export function BatchManager() { const [month, setMonth] = useState(new Date().toISOString().slice(0, 7)) const [monthFrom, setMonthFrom] = useState('') const [monthTo, setMonthTo] = useState('') + const [dateFrom, setDateFrom] = useState('') + const [dateTo, setDateTo] = useState('') const [filterStatus, setFilterStatus] = useState('') const [filterType, setFilterType] = useState('') const [selectedBatchId, setSelectedBatchId] = useState(null) @@ -120,12 +122,14 @@ export function BatchManager() { }) const { data: batches, isLoading } = useQuery({ - queryKey: ['batches', month, monthFrom, monthTo, filterStatus, filterType], + queryKey: ['batches', month, monthFrom, monthTo, dateFrom, dateTo, filterStatus, filterType], queryFn: async () => { const params: any = {} - if (month && !monthFrom && !monthTo) params.month = month + if (month && !monthFrom && !monthTo && !dateFrom && !dateTo) params.month = month if (monthFrom) params.monthFrom = monthFrom if (monthTo) params.monthTo = monthTo + if (dateFrom) params.dateFrom = dateFrom + if (dateTo) params.dateTo = dateTo if (filterStatus) params.status = filterStatus if (filterType) params.type = filterType return await payrollApi.batches(params) @@ -215,9 +219,15 @@ export function BatchManager() { { setMonthTo(e.target.value); setMonth('') }} className="!w-36" /> - {!monthFrom && !monthTo && ( + {!monthFrom && !monthTo && !dateFrom && !dateTo && ( setMonth(e.target.value)} className="!w-36 shrink-0" placeholder="单月" /> )} +
+ 创建日期 + { setDateFrom(e.target.value); setMonth('') }} className="!w-36" placeholder="起始" /> + ~ + { setDateTo(e.target.value); setMonth('') }} className="!w-36" placeholder="截止" /> +
- {(monthFrom || monthTo || filterStatus || filterType) && ( - )} @@ -327,6 +337,7 @@ export function BatchManager() { 公积金合计 个税合计 实发合计 + 创建时间 状态 操作 @@ -376,6 +387,7 @@ export function BatchManager() { ¥{fmt((batch.totalHousingOrg || 0) + (batch.totalHousingEmp || 0))} ¥{fmt(batch.totalTax)} ¥{fmt(batch.totalNetPay)} + {batch.createdAt ? new Date(batch.createdAt).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '-'} {batch.status === 'ARCHIVED' ? ( diff --git a/frontend/src/pages/roster/BasicInfo.tsx b/frontend/src/pages/roster/BasicInfo.tsx index 53127f7..15deae1 100644 --- a/frontend/src/pages/roster/BasicInfo.tsx +++ b/frontend/src/pages/roster/BasicInfo.tsx @@ -3,6 +3,7 @@ import { useState, useRef } from "react" import { toast } from "sonner" import { useMutation, useQueryClient } from "@tanstack/react-query" import { attachmentApi, employeeApi } from '../../lib/api-services' +import { copyToClipboard } from '../../lib/clipboard' import Card from "../../components/ui/Card" import Button from "../../components/ui/Button" import { Input, Label, Select } from "../../components/ui/Input" @@ -203,9 +204,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil className="text-gray-400 hover:text-primary transition-colors shrink-0" title="复制身份证号" onClick={() => { - navigator.clipboard.writeText(profile.idCardNumber) - .then(() => toast.success('已复制身份证号')) - .catch(() => toast.error('复制失败')) + copyToClipboard(profile.idCardNumber, '已复制身份证号') }} > diff --git a/frontend/src/pages/roster/ContractInfo.tsx b/frontend/src/pages/roster/ContractInfo.tsx index 6e8a438..4f89343 100644 --- a/frontend/src/pages/roster/ContractInfo.tsx +++ b/frontend/src/pages/roster/ContractInfo.tsx @@ -1,4 +1,5 @@ import { useState, useRef } from "react" +import api from '../../lib/api' import { toast } from "sonner" import { useMutation, useQueryClient } from "@tanstack/react-query" import { employeeApi, esignApi } from '../../lib/api-services' @@ -14,7 +15,59 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl const [showForm, setShowForm] = useState(false) const [form, setForm] = useState({ contractType: 'FIXED', signDate: '', startDate: '', endDate: '', contractYears: 3, probationMonths: 0, probationSalary: 0, signMethod: 'PAPER' as 'PAPER' | 'ELECTRONIC', attachmentUrl: '', attachments: [] as { name: string; url: string }[], electronicContractNo: '', electronicContractUrl: '' }) const contractFileRef = useRef(null) + const supplementFileRefs = useRef>({}) const [previewUrl, setPreviewUrl] = useState(null) + const [previewName, setPreviewName] = useState('附件') + + const uploadAttachmentMutation = useMutation({ + mutationFn: async ({ contractId, attachmentUrl }: { contractId: string; attachmentUrl: string }) => { + await api.patch(`/employees/contracts/${contractId}/attachment`, { attachmentUrl }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) + toast.success('附件已上传') + }, + onError: () => toast.error('上传失败'), + }) + + const handleSupplementUpload = (e: React.ChangeEvent, contractId: string, existingAtts: { name: string; url: string }[]) => { + const files = e.target.files + if (!files || files.length === 0) return + const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic', '.gif', '.bmp', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.tiff', '.tif'] + const maxSize = 10 * 1024 * 1024 + const validFiles: File[] = [] + for (const file of Array.from(files)) { + const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.')) + if (!allowedExts.includes(ext)) { + toast.error(`不支持的文件格式: ${file.name}`) + continue + } + if (file.size > maxSize) { + toast.error(`文件过大: ${file.name}(最大 10MB)`) + continue + } + validFiles.push(file) + } + if (validFiles.length === 0) return + const promises = validFiles.map(file => new Promise<{ name: string; url: string }>((resolve) => { + const reader = new FileReader() + reader.onload = (event) => { + resolve({ name: file.name, url: event.target?.result as string }) + } + reader.onerror = () => { + toast.error(`读取文件失败: ${file.name}`) + resolve({ name: file.name, url: '' }) + } + reader.readAsDataURL(file) + })) + Promise.all(promises).then(atts => { + const validAtts = atts.filter(a => a.url) + if (validAtts.length === 0) return + const merged = [...existingAtts, ...validAtts] + uploadAttachmentMutation.mutate({ contractId, attachmentUrl: JSON.stringify(merged) }) + }) + e.target.value = '' + } const addContractMutation = useMutation({ mutationFn: async (data: any) => { @@ -43,7 +96,7 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl const deleteContractMutation = useMutation({ mutationFn: (contractId: string) => employeeApi.removeContract(contractId), - onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); toast.success('合同已删除') }, + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); toast.success('合同已作废') }, }) const handleContractFileUpload = (e: React.ChangeEvent) => { @@ -261,6 +314,7 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl {(() => { let atts: { name: string; url: string }[] = [] try { + if (!c.attachmentUrl) throw new Error('empty') const parsed = JSON.parse(c.attachmentUrl) atts = Array.isArray(parsed) ? parsed : [{ name: '附件', url: c.attachmentUrl }] } catch { @@ -270,19 +324,45 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl atts = [{ name: `附件.${ext}`, url: c.attachmentUrl }] } } - if (atts.length === 0) return 未上传 return (
+ {atts.length === 0 && 未上传} {atts.map((att, idx) => (
- - +
))} + handleSupplementUpload(e, c.id, atts)} /> +
) })()} @@ -298,9 +378,9 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl )} @@ -332,9 +412,16 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
附件预览
- + @@ -349,9 +436,16 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl )}
diff --git a/frontend/src/pages/roster/DisciplinaryInfo.tsx b/frontend/src/pages/roster/DisciplinaryInfo.tsx index cadff3f..e21cbfe 100644 --- a/frontend/src/pages/roster/DisciplinaryInfo.tsx +++ b/frontend/src/pages/roster/DisciplinaryInfo.tsx @@ -4,7 +4,9 @@ import { rosterApi } from '../../lib/api-services' import Card from "../../components/ui/Card" import Button from "../../components/ui/Button" import { Input, Label, Select } from "../../components/ui/Input" -import { AlertTriangle, Check } from "lucide-react" +import { AlertTriangle, Check, Download } from "lucide-react" +import { toast } from "sonner" +import { useAuthStore } from '../../store/authStore' // ========== 违纪记录管理 ========== @@ -104,7 +106,34 @@ export default function DisciplinaryInfo({ employeeId, records }: { employeeId: {r.ackMethod && 确认方式:{r.ackMethod === 'SIGN' ? '签字' : r.ackMethod === 'ELECTRONIC' ? '电子' : '拒绝'}}
- +
+ {r.employeeAck && ( + + )} + +
))} diff --git a/frontend/src/pages/roster/DisciplinaryRecords.tsx b/frontend/src/pages/roster/DisciplinaryRecords.tsx index 0e1977c..97bdfe4 100644 --- a/frontend/src/pages/roster/DisciplinaryRecords.tsx +++ b/frontend/src/pages/roster/DisciplinaryRecords.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Link } from 'react-router-dom' -import { Search, Plus, Edit2, Trash2, X } from 'lucide-react' +import { Search, Plus, Edit2, Trash2, X, Download } from 'lucide-react' import { toast } from 'sonner' import { rosterApi, employeeApi } from '../../lib/api-services' import api from '../../lib/api' @@ -139,6 +139,35 @@ export default function DisciplinaryRecords() {
+ {r.employeeAck && ( + + )} diff --git a/frontend/src/pages/roster/PerformanceInfo.tsx b/frontend/src/pages/roster/PerformanceInfo.tsx index 8391e41..6a1b749 100644 --- a/frontend/src/pages/roster/PerformanceInfo.tsx +++ b/frontend/src/pages/roster/PerformanceInfo.tsx @@ -11,7 +11,7 @@ import { AlertTriangle, Check } from "lucide-react" export default function PerformanceInfo({ employeeId, records }: { employeeId: string; records: any[] }) { const queryClient = useQueryClient() const [showForm, setShowForm] = useState(false) - const [form, setForm] = useState({ period: '', score: 80, grade: 'B', result: 'QUALIFIED', summary: '', improvementPlan: '', employeeAck: false, ackDate: '', reviewer: '' }) + const [form, setForm] = useState({ period: '', periodType: 'MONTHLY' as 'MONTHLY' | 'QUARTERLY' | 'YEARLY', score: 80, grade: 'B', result: 'QUALIFIED', summary: '', improvementPlan: '', employeeAck: false, ackDate: '', reviewer: '' }) const createMutation = useMutation({ mutationFn: (data: any) => rosterApi.performance(employeeId, data), @@ -25,6 +25,19 @@ export default function PerformanceInfo({ employeeId, records }: { employeeId: s const resultMap: Record = { EXCELLENT: '优秀', QUALIFIED: '合格', NEED_IMPROVE: '需改进', UNQUALIFIED: '不胜任' } + // 根据得分自动计算等级和结果 + const scoreToGrade = (score: number): { grade: string; result: string } => { + if (score >= 90) return { grade: 'A', result: 'EXCELLENT' } + if (score >= 80) return { grade: 'B', result: 'QUALIFIED' } + if (score >= 60) return { grade: 'C', result: 'NEED_IMPROVE' } + return { grade: 'D', result: 'UNQUALIFIED' } + } + + const handleScoreChange = (score: number) => { + const { grade, result } = scoreToGrade(score) + setForm({ ...form, score, grade, result }) + } + return (
@@ -35,14 +48,21 @@ export default function PerformanceInfo({ employeeId, records }: { employeeId: s {showForm && (
-
setForm({ ...form, period: e.target.value })} placeholder="如 2026-07 或 2026-Q3" />
-
setForm({ ...form, score: Number(e.target.value) })} />
-
+
+ +
+
setForm({ ...form, period: e.target.value })} placeholder={form.periodType === 'MONTHLY' ? '如 2026-07' : form.periodType === 'QUARTERLY' ? '如 2026-Q3' : '如 2026'} />
+
handleScoreChange(Number(e.target.value))} />
+
-
+
diff --git a/frontend/src/pages/roster/PerformanceRecords.tsx b/frontend/src/pages/roster/PerformanceRecords.tsx index 1529a9d..3d8dc70 100644 --- a/frontend/src/pages/roster/PerformanceRecords.tsx +++ b/frontend/src/pages/roster/PerformanceRecords.tsx @@ -181,6 +181,7 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: { const [form, setForm] = useState({ employeeId: record?.employeeId || '', period: record?.period || new Date().toISOString().slice(0, 7), + periodType: record?.periodType || 'MONTHLY', score: record?.score || 80, grade: record?.grade || 'B', result: record?.result || 'QUALIFIED', @@ -189,6 +190,18 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: { reviewer: record?.reviewer || '', }) + const scoreToGrade = (score: number): { grade: string; result: string } => { + if (score >= 90) return { grade: 'A', result: 'EXCELLENT' } + if (score >= 80) return { grade: 'B', result: 'QUALIFIED' } + if (score >= 60) return { grade: 'C', result: 'NEED_IMPROVE' } + return { grade: 'D', result: 'UNQUALIFIED' } + } + + const handleScoreChange = (score: number) => { + const { grade, result } = scoreToGrade(score) + setForm({ ...form, score, grade, result }) + } + return (
e.stopPropagation()}> @@ -197,6 +210,16 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: {
+ {!record && ( +
+ + +
+ )} {!record && (
@@ -210,15 +233,15 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: { )}
- setForm({ ...form, period: e.target.value })} /> + setForm({ ...form, period: e.target.value })} placeholder={form.periodType === 'YEARLY' ? '如 2026' : undefined} />
- setForm({ ...form, score: Number(e.target.value) })} /> + handleScoreChange(Number(e.target.value))} />
- + setForm({ ...form, result: e.target.value })}> diff --git a/frontend/src/pages/tools/MedicalPeriodCalculator.tsx b/frontend/src/pages/tools/MedicalPeriodCalculator.tsx index 1591e4e..2bc82f7 100644 --- a/frontend/src/pages/tools/MedicalPeriodCalculator.tsx +++ b/frontend/src/pages/tools/MedicalPeriodCalculator.tsx @@ -1,14 +1,29 @@ /** * 医疗期计算器 - * 根据员工工龄和地区计算法定医疗期天数 - * 法律依据:《企业职工患病或非因工负伤医疗期规定》(劳部发[1994]479号) - * 上海特殊规定:沪府发[2015]40号 + * 根据员工工龄和地区政策计算法定医疗期天数 + * 支持自定义地区政策(数据驱动) */ -import { useState } from 'react' +import { useState, useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' import { Calculator, HeartPulse, Info } from 'lucide-react' import Card from '../../components/ui/Card' import Button from '../../components/ui/Button' +import { settingsApi } from '../../lib/api-services' + +interface PolicyRule { + maxYears: number + months: number + cycleMonths: number +} + +interface MedicalPeriodPolicy { + id: string + region: string + legalBasis: string + rules: PolicyRule[] + isDefault: boolean +} interface MedicalPeriodResult { totalMonths: number @@ -19,76 +34,24 @@ interface MedicalPeriodResult { notes: string[] } -/** - * 计算医疗期 - * @param workYears 本单位工作年限 - * @param region 地区(上海/全国) - * @param sickDays 累计病休天数 - * @param startDate 开始病休日期 - */ function calculateMedicalPeriod( workYears: number, - region: 'shanghai' | 'national', + policy: MedicalPeriodPolicy, sickDays: number, startDate: string, ): MedicalPeriodResult | null { - if (!startDate || workYears < 0) return null + if (!startDate || workYears < 0 || !policy.rules.length) return null - let totalMonths: number - let cumulativeDays: number - let legalBasis: string + const rule = policy.rules.find(r => workYears < r.maxYears) || policy.rules[policy.rules.length - 1] + const totalMonths = rule.months + const cumulativeDays = rule.cycleMonths * 30 + const legalBasis = policy.legalBasis const notes: string[] = [] - if (region === 'shanghai') { - // 上海特殊规定:直接按工龄分档 - if (workYears < 1) { - totalMonths = 3 - cumulativeDays = 6 * 30 // 6个月周期 - legalBasis = '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》' - } else if (workYears < 4) { - totalMonths = 3 - cumulativeDays = 6 * 30 - legalBasis = '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》' - } else if (workYears < 10) { - totalMonths = 6 - cumulativeDays = 12 * 30 - legalBasis = '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》' - } else { - totalMonths = 9 - cumulativeDays = 18 * 30 - legalBasis = '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》' - } - notes.push('上海地区适用特殊规定,医疗期不按累计病休天数折算') - } else { - // 全国通用规定:劳部发[1994]479号 - if (workYears < 5) { - totalMonths = 3 - cumulativeDays = 6 * 30 // 6个月内累计病休 - legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)' - } else if (workYears < 10) { - totalMonths = 6 - cumulativeDays = 12 * 30 // 12个月内累计病休 - legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)' - } else if (workYears < 15) { - totalMonths = 9 - cumulativeDays = 15 * 30 // 15个月内累计病休 - legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)' - } else if (workYears < 20) { - totalMonths = 12 - cumulativeDays = 18 * 30 // 18个月内累计病休 - legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)' - } else { - totalMonths = 24 - cumulativeDays = 30 * 30 // 30个月内累计病休 - legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)' - } - notes.push(`在 ${cumulativeDays / 30} 个月的累计周期内,病休累计不超过 ${totalMonths} 个月即享有医疗期保护`) - } + notes.push(`在 ${rule.cycleMonths} 个月的累计周期内,病休累计不超过 ${totalMonths} 个月即享有医疗期保护`) - // 计算实际可用天数 const actualDays = Math.max(0, totalMonths * 30 - sickDays) - // 计算医疗期结束日期 const start = new Date(startDate) const endDate = new Date(start) endDate.setMonth(endDate.getMonth() + totalMonths) @@ -110,21 +73,33 @@ function calculateMedicalPeriod( * 医疗期计算器页面 */ export default function MedicalPeriodCalculator() { - const [region, setRegion] = useState<'national' | 'shanghai'>('national') + const [selectedPolicyId, setSelectedPolicyId] = useState('') const [workYears, setWorkYears] = useState('') const [sickDays, setSickDays] = useState('0') const [startDate, setStartDate] = useState('') const [result, setResult] = useState(null) + const { data: policies = [] } = useQuery({ + queryKey: ['medical-period-policies'], + queryFn: () => settingsApi.medicalPeriodPolicies(), + }) + + const selectedPolicy = useMemo(() => { + if (!policies.length) return null + if (selectedPolicyId) return policies.find(p => p.id === selectedPolicyId) || null + return policies.find(p => p.isDefault) || policies[0] + }, [policies, selectedPolicyId]) + const handleCalculate = () => { const years = parseFloat(workYears) || 0 const days = parseInt(sickDays) || 0 - const r = calculateMedicalPeriod(years, region, days, startDate) + if (!selectedPolicy) return + const r = calculateMedicalPeriod(years, selectedPolicy, days, startDate) setResult(r) } const handleReset = () => { - setRegion('national') + setSelectedPolicyId('') setWorkYears('') setSickDays('0') setStartDate('') @@ -144,12 +119,13 @@ export default function MedicalPeriodCalculator() {
@@ -252,28 +228,36 @@ export default function MedicalPeriodCalculator() { )} - {/* 工龄分档表 */} - -

医疗期分档表(全国通用)

-
- - - - - - - - - - - - - - - -
工作年限医疗期累计周期
不满 5 年3 个月6 个月
5-10 年6 个月12 个月
10-15 年9 个月15 个月
15-20 年12 个月18 个月
20 年以上24 个月30 个月
-
-
+ {/* 当前政策分档表 */} + {selectedPolicy && ( + +

医疗期分档表({selectedPolicy.region})

+
+ + + + + + + + + + {selectedPolicy.rules.map((rule, idx) => { + const prevMax = idx > 0 ? selectedPolicy.rules[idx - 1].maxYears : 0 + const isLast = idx === selectedPolicy.rules.length - 1 + return ( + + + + + + ) + })} + +
工作年限医疗期累计周期
{isLast ? `${prevMax} 年以上` : `${prevMax}-${rule.maxYears} 年`}{rule.months} 个月{rule.cycleMonths} 个月
+
+
+ )}
) }