feat: 20260805 系统优化 - 身份证复制fallback/薪税日期筛选/社保版本修复/证据链导出/违纪证明/医疗期政策/绩效类型评级/合同作废/帮助更新
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' }
|
||||
const actionMap: Record<string, string> = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '解除劳动合同' }
|
||||
const severityMap: Record<string, string> = { 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) => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
// 计算上个版本的失效月份 = 新版本生效月份的前一个月
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),使用 `<a href={att.url} download={att.name}>` 下载
|
||||
- 附件以 base64 data URL 形式存储在数据库中,`<a>` 标签的 `download` 属性对 data URL 在某些浏览器下不生效
|
||||
- 下载无反应的可能原因:
|
||||
1. data URL 过长,浏览器阻止下载
|
||||
2. `attachmentUrl` 字段存储的是 JSON 字符串,解析失败时回退逻辑可能未正确处理
|
||||
3. `<a>` 标签点击事件被外层 `<button>` 或其他事件拦截
|
||||
- 删除问题:
|
||||
- 前端 `ContractInfo.tsx:300-306`:有删除按钮,调用 `deleteContractMutation`
|
||||
- 后端 `employee.routes.ts:279-294`:`DELETE /contracts/:contractId` 直接物理删除合同记录
|
||||
- 合同作为重要法律文件,应禁止删除,仅允许新增或修改(覆盖)
|
||||
|
||||
**涉及文件**:
|
||||
- `frontend/src/pages/roster/ContractInfo.tsx:258-289, 300-306`
|
||||
- `backend/src/routes/employee.routes.ts:279-294`
|
||||
- `backend/src/services/contract.service.ts:713-764`
|
||||
|
||||
**优化方案**:
|
||||
1. **下载修复**:
|
||||
- 将 data URL 转为 Blob URL 后再触发下载(已有 `dataToBlobUrl` 函数用于预览,下载也应用相同逻辑)
|
||||
- 下载按钮改为 `onClick` 事件主动创建 `<a>` 元素并 click,而非依赖 `<a>` 标签的 `download` 属性
|
||||
- 或改为调用后端接口下载(后端返回文件流),避免前端处理大 data URL
|
||||
2. **禁止删除**:
|
||||
- 移除前端删除按钮,改为「作废」按钮(将合同标记为 `VOID` 状态而非物理删除)
|
||||
- 后端 `DELETE /contracts/:contractId` 改为 `PATCH /contracts/:contractId/void`,仅更新状态
|
||||
- schema 中 `LaborContract` 增加 `status` 字段(`ACTIVE`/`VOID`),作废后不在正常列表展示但保留记录
|
||||
- 证据链中保留作废合同记录,标注"已作废"
|
||||
3. **允许覆盖**:新增合同时若日期完全相同则提示"已存在相同日期合同,确认覆盖?"(当前是直接报错拒绝)
|
||||
|
||||
---
|
||||
|
||||
## 优先级汇总
|
||||
|
||||
| 编号 | 问题 | 优先级 | 模块 |
|
||||
|------|------|--------|------|
|
||||
| 1 | 花名册身份证号复制乱码 | P0 | 花名册 |
|
||||
| 2 | 薪税管理筛选精确到日+创建时间 | P1 | 薪税管理 |
|
||||
| 3 | 社保公积金无法创建保存新政策 | P0 | 社保公积金 |
|
||||
| 4 | 证据链导出失败 | P0 | 证据链 |
|
||||
| 5 | 离职证明模板选择+格式+电子签章 | P0 | 用工办理 |
|
||||
| 6 | 用工办理模块功能重复 | P2 | 用工办理 |
|
||||
| 7 | 违纪记录签字后下载确认证明 | P0 | 违纪记录 |
|
||||
| 8 | 医疗期计算增加其他地区政策 | P2 | 医疗期计算器 |
|
||||
| 9 | 绩效考核月度/年度区分+得分等级关联 | P0 | 绩效考核 |
|
||||
| 10 | 劳动合同无法下载+不应能删除 | P0 | 花名册 |
|
||||
|
||||
---
|
||||
|
||||
## 已确认无需修改
|
||||
|
||||
(暂无)
|
||||
@@ -31,125 +31,52 @@ const categories: HelpCategory[] = [
|
||||
icon: Sparkles,
|
||||
articles: [
|
||||
{
|
||||
id: 'update-training-list',
|
||||
question: '新增:培训记录列表页',
|
||||
answer: '在左侧菜单「团队」分组下新增「培训记录」入口,支持全员培训记录的集中管理:\n• 列表展示所有员工的培训记录,含签收状态(待签收/已签收/拒绝签收)\n• 支持按员工姓名搜索和分页\n• 可直接在列表新增、编辑、删除培训记录\n• 员工可在员工端「我的记录」中签收或拒绝培训记录',
|
||||
tip: '路径:左侧菜单 → 团队 → 培训记录',
|
||||
id: 'update-idcard-clipboard',
|
||||
question: '修复:花名册身份证号复制乱码',
|
||||
answer: '修复花名册列表和员工详情页点击身份证号复制后粘贴为乱码的问题:\n• 新增统一的 clipboard 工具函数,优先使用 navigator.clipboard API\n• 当 clipboard API 不可用时(非 HTTPS 或浏览器不兼容),自动降级为 execCommand("copy") + 隐藏 textarea 方案\n• 复制失败时提示「复制失败,请手动复制」',
|
||||
tip: '路径:花名册列表点击身份证号 / 员工详情点击复制图标',
|
||||
},
|
||||
{
|
||||
id: 'update-performance-list',
|
||||
question: '新增:绩效考核列表页',
|
||||
answer: '在左侧菜单「团队」分组下新增「绩效考核」入口,支持全员绩效记录的集中管理:\n• 列表展示所有员工的绩效记录,含签字状态(待签字/已签字)\n• 支持按员工姓名搜索和分页\n• 可直接在列表新增、编辑、删除绩效记录\n• 员工可在员工端「我的记录」中签字确认绩效结果',
|
||||
tip: '路径:左侧菜单 → 团队 → 绩效考核',
|
||||
id: 'update-payroll-date-filter',
|
||||
question: '优化:薪税管理筛选支持创建日期范围',
|
||||
answer: '薪税管理批次列表新增「创建日期」范围筛选器:\n• 在原有月份筛选基础上,增加 dateFrom / dateTo 日期选择器\n• 可按批次创建时间精确筛选某天或某段日期的工资批次\n• 批次列表默认按创建时间倒序排列\n• 列表新增「创建时间」列,显示 YYYY-MM-DD HH:mm 格式\n• 方便同月多笔工资批次的区分和查找',
|
||||
tip: '路径:薪税管理 → 批次列表 → 创建日期筛选',
|
||||
},
|
||||
{
|
||||
id: 'update-disciplinary-list',
|
||||
question: '新增:违纪记录列表页',
|
||||
answer: '在左侧菜单「团队」分组下新增「违纪记录」入口,支持全员违纪记录的集中管理:\n• 列表展示所有员工的违纪记录,含签字状态(待签字/已签字)\n• 支持按员工姓名搜索和分页\n• 可直接在列表新增、编辑、删除违纪记录\n• 员工可在员工端「我的记录」中签字确认违纪记录',
|
||||
tip: '路径:左侧菜单 → 团队 → 违纪记录',
|
||||
id: 'update-social-insurance-fix',
|
||||
question: '修复:社保公积金无法创建新政策版本',
|
||||
answer: '修复社保公积金页面创建新版本政策比例时保存失败的问题:\n• 后端将旧版本标记为失效时增加城市过滤条件,避免误将其他城市的当前版本标记失效\n• 前端创建版本失败时显示后端返回的具体错误信息,方便排查\n• 社保和公积金均修复此问题',
|
||||
tip: '路径:社保公积金 → 新建版本',
|
||||
},
|
||||
{
|
||||
id: 'update-employee-records',
|
||||
question: '新增:员工端「我的记录」签收功能',
|
||||
answer: '员工端新增「我的记录」页面,包含三个标签页:\n• 培训记录:查看培训详情,可签收或拒绝签收\n• 绩效考核:查看考核结果,可签字确认\n• 违纪记录:查看违纪详情,可签字确认\n所有签收操作自动记录IP和时间戳,并生成证据链记录。',
|
||||
tip: '路径:员工端导航 → 我的记录',
|
||||
id: 'update-evidence-export',
|
||||
question: '修复:证据链导出失败',
|
||||
answer: '修复员工档案中证据链导出提示「导出失败」的问题:\n• 后端 ExcelJS 生成方式从流式写入改为 writeBuffer + res.send,避免流写入异常\n• 增加错误日志输出,方便排查具体失败原因\n• 前端导出请求携带认证 token',
|
||||
tip: '路径:员工档案 → 证据链 → 导出证据链',
|
||||
},
|
||||
{
|
||||
id: 'update-esign-settings',
|
||||
question: '新增:电子签署设置扩展',
|
||||
answer: '系统设置 → 企业信息 → 电子签署设置,新增3个开关,共6个电子签场景:\n• 规章制度电子签(原有)\n• 工资条电子签(原有)\n• 入职文件电子签(原有)\n• 培训记录电子签(新增)— 开启后员工签收培训记录时需电子签署\n• 绩效考核电子签(新增)— 开启后员工签字确认绩效时需电子签署\n• 违纪记录电子签(新增)— 开启后员工签字确认违纪记录时需电子签署\n采用两列卡片布局,每个开关独立切换,点击即保存。',
|
||||
tip: '路径:设置 → 企业信息 → 电子签署设置',
|
||||
id: 'update-disciplinary-certificate',
|
||||
question: '新增:违纪记录签字后可下载确认证明',
|
||||
answer: '员工在员工端签字确认违纪记录后,企业端可下载违纪确认证明文件:\n• 后端新增 GET /roster/:employeeId/disciplinary/:recordId/certificate 接口\n• 证明内容包含:企业名称、员工姓名、身份证号、违纪事实、处理结果、签字确认状态、确认日期、见证人\n• 前端在已签字的违纪记录上显示下载按钮',
|
||||
tip: '路径:团队 → 违纪记录 → 已签字记录点击下载按钮',
|
||||
},
|
||||
{
|
||||
id: 'update-rename',
|
||||
question: '优化:社公商保更名为社保公积金',
|
||||
answer: '侧边栏菜单和页面标题中的「社公商保」已更名为「社保公积金」,名称更清晰直观。',
|
||||
id: 'update-medical-period-policy',
|
||||
question: '新增:医疗期计算器支持自定义地区政策',
|
||||
answer: '医疗期计算器从硬编码改为数据驱动,支持动态添加地区政策:\n• 系统设置新增「医疗期政策」管理页面,可增删改各地区政策\n• 每个地区政策包含:地区名称、法律依据、工龄分档规则(工龄上限 → 医疗期月数 → 累计周期月数)\n• 预置全国通用、上海、石家庄、唐山四个地区政策\n• 医疗期计算器从 API 动态加载政策列表,选择地区后自动应用对应规则',
|
||||
tip: '路径:设置 → 医疗期政策 / 工具 → 医疗期计算器',
|
||||
},
|
||||
{
|
||||
id: 'update-payroll-default',
|
||||
question: '优化:发薪日默认改为每月5日',
|
||||
answer: '企业信息中发薪日默认值从每月10日改为每月5日,可在设置中自行修改。',
|
||||
id: 'update-performance-type-grade',
|
||||
question: '优化:绩效考核区分月度/季度/年度,得分自动关联等级',
|
||||
answer: '绩效考核新增考核类型字段,得分与等级自动联动:\n• 新增 periodType 字段:月度考核(MONTHLY)、季度考核(QUARTERLY)、年度考核(YEARLY)\n• 根据考核类型动态切换周期输入方式:月度→月份选择器,季度→Q1/Q2/Q3/Q4,年度→年份输入\n• 得分自动计算等级和结果:90-100→A(优秀),80-89→B(合格),60-79→C(需改进),0-59→D(不胜任)\n• 等级和结果字段由得分自动填充,也可手动覆盖',
|
||||
tip: '路径:团队 → 绩效考核 → 新增 / 员工档案 → 绩效考核',
|
||||
},
|
||||
{
|
||||
id: 'update-roster-idcard-search',
|
||||
question: '修复:花名册身份证号搜索',
|
||||
answer: '修复身份证号搜索结果被前端二次过滤清空的问题,增加 idCardMasked 字段匹配,现在输入身份证后4位即可正确搜索到员工。',
|
||||
},
|
||||
{
|
||||
id: 'update-topnav-orgname',
|
||||
question: '优化:顶部导航栏显示企业名称',
|
||||
answer: 'TopNav 增加当前登录企业名称显示,方便多组织用户快速识别当前操作的企业。',
|
||||
},
|
||||
{
|
||||
id: 'update-workprocess-i18n',
|
||||
question: '修复:用工办理表单字段显示中文',
|
||||
answer: 'WorkProcess 表单信息展示区域增加字段名中文映射,将 employeeName → 员工姓名、idCardNumber → 身份证号等英文 key 显示为中文标签,不再出现"乱码"。',
|
||||
},
|
||||
{
|
||||
id: 'update-dashboard-tabs',
|
||||
question: '优化:工作台拆分为5个Tab',
|
||||
answer: '首页工作台从单一概览拆分为 5 个 Tab:概览、风险提醒、月度任务、人力成本、人员分析。支持 Tab 级别和区域级别的显示设置,信息更清晰。',
|
||||
},
|
||||
{
|
||||
id: 'update-esign-scene',
|
||||
question: '新增:电子签署场景分类',
|
||||
answer: '电子签署记录新增 scene 字段,支持 5 种场景分类:合同签署、离职签署、规章制度、工资条、入职文件。\n• 管理端电子签署列表增加「全部场景」下拉筛选\n• 管理端和员工端列表均显示场景标签(不同颜色区分)\n• 原有合同和离职签署自动归入对应场景',
|
||||
tip: '路径:左侧菜单 → 电子签署',
|
||||
},
|
||||
{
|
||||
id: 'update-notify-payroll',
|
||||
question: '优化:通知设置去掉冗余发薪日字段',
|
||||
answer: '通知设置中的「发薪日(每月几号)」字段已移除,发薪日统一在企业信息中设置,避免重复配置。',
|
||||
},
|
||||
{
|
||||
id: 'update-roster-payroll-btn',
|
||||
question: '修复:花名册发薪按钮无效',
|
||||
answer: '修复花名册列表中「发薪」按钮使用 hash 路由跳转不兼容的问题,改用 navigate 路由跳转,现在点击发薪按钮可正常进入薪税管理页面。',
|
||||
},
|
||||
{
|
||||
id: 'update-workprocess-employee-select',
|
||||
question: '优化:用工办理支持姓名检索员工',
|
||||
answer: '用工办理表单中「员工ID」字段改为姓名检索下拉选择组件,输入姓名即可模糊搜索员工列表,选择后自动填入 ID,不再需要手动输入 UUID。同时合同字段也改为根据选中员工自动列出其合同供选择。',
|
||||
},
|
||||
{
|
||||
id: 'update-workprocess-direct-submit',
|
||||
question: '新增:用工办理填写界面直接提交',
|
||||
answer: '用工办理创建弹窗底部新增「直接提交」按钮,填写完信息后可一键创建并提交,无需先存草稿再手动提交。同时保留「存草稿」按钮供用户选择。',
|
||||
},
|
||||
{
|
||||
id: 'update-riskcenter-filter',
|
||||
question: '修复:风险中心跳转后自动筛选',
|
||||
answer: '风险中心列表项点击跳转时自动携带员工姓名等查询参数,花名册页面读取后自动筛选对应员工,无需手动搜索。同时增加续签、转正、处理等快捷操作按钮。',
|
||||
},
|
||||
{
|
||||
id: 'update-termination-validation',
|
||||
question: '修复:离职操作增加必填校验',
|
||||
answer: '解聘管理向导各步骤增加必填项校验:\n• 合规检查步骤:所有标记为「必检」的检查项必须勾选才能继续\n• 工作交接步骤:工作交接、设备归还、权限收回必须完成才能继续\n• 未完成时显示红色提示文字,下一步按钮禁用',
|
||||
tip: '路径:左侧菜单 → 解聘管理',
|
||||
},
|
||||
{
|
||||
id: 'update-termination-comp-adjust',
|
||||
question: '修复:补偿金手动调整后确认阶段显示实际金额',
|
||||
answer: '解聘管理费用结算步骤支持手动调整补偿金各分项金额,需填写调整原因:\n• 点击各分项金额可弹出编辑框修改\n• 确认阶段显示「系统预估 + 手动调整明细 = 实际补偿金合计」\n• 调整记录保存到草稿中,不会丢失',
|
||||
tip: '路径:左侧菜单 → 解聘管理 → 费用结算',
|
||||
},
|
||||
{
|
||||
id: 'update-retirement-reminder',
|
||||
question: '新增:退休提醒功能',
|
||||
answer: '系统设置新增「退休提醒」Tab,开启后自动检测即将达到法定退休年龄的员工:\n• 可配置退休年龄标准(男60岁、女50岁/55岁)\n• 到期前30天自动提醒\n• 风险中心自动汇总退休风险',
|
||||
tip: '路径:设置 → 退休提醒',
|
||||
},
|
||||
{
|
||||
id: 'update-leave-approval-nav',
|
||||
question: '优化:休假审批入口显性化',
|
||||
answer: '侧边栏「时间」分组下新增「休假审批」菜单入口,方便HR直接进入审批页面,不再需要从考勤管理中寻找。',
|
||||
tip: '路径:左侧菜单 → 时间 → 休假审批',
|
||||
},
|
||||
{
|
||||
id: 'update-employee-resignation',
|
||||
question: '新增:员工端辞职申请',
|
||||
answer: '员工端新增「辞职申请」功能:\n• 员工可填写辞职原因、预计离职日期\n• 支持上传辞职信照片(多张)\n• 提交后HR端自动收到通知\n• HR确认后进入正式离职流程',
|
||||
tip: '路径:员工端 → 辞职申请',
|
||||
id: 'update-contract-download-void',
|
||||
question: '修复:劳动合同附件下载 + 禁止删除改为作废',
|
||||
answer: '修复员工花名册中劳动合同附件无法下载的问题,并将删除改为作废:\n• 下载修复:附件下载改为程序化创建 <a> 元素并触发点击,避免 data URL 在部分浏览器下下载无效\n• 新增补充上传功能:已有附件的合同可追加上传更多附件\n• 禁止物理删除:删除按钮改为「作废合同」,设置结束日期为当前时间,保留记录但不再生效\n• 作废操作记录审计日志',
|
||||
tip: '路径:员工档案 → 劳动合同 → 附件下载 / 作废按钮',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -46,10 +46,10 @@ const navGroups: NavGroup[] = [
|
||||
{ path: '/roster', label: '花名册', icon: Users },
|
||||
{ path: '/work-process', label: '用工办理', icon: ClipboardList },
|
||||
{ path: '/termination', label: '离职管理', icon: UserX },
|
||||
{ path: '/special-status', label: '特殊员工', icon: Heart },
|
||||
{ path: '/training-records', label: '培训记录', icon: GraduationCap },
|
||||
{ path: '/performance-records', label: '绩效考核', icon: TrendingUp },
|
||||
{ path: '/disciplinary-records', label: '违纪记录', icon: AlertTriangle },
|
||||
{ path: '/special-status', label: '特殊员工', icon: Heart },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -798,6 +798,15 @@ export const settingsApi = {
|
||||
/** 确认退休政策生效 */
|
||||
confirmRetirementPolicy: (id: string) =>
|
||||
post(`/settings/retirement-policy/${id}/confirm`),
|
||||
/** 医疗期政策列表 */
|
||||
medicalPeriodPolicies: () =>
|
||||
get('/settings/medical-period/policies').then(unwrap<any[]>()),
|
||||
/** 保存医疗期政策 */
|
||||
saveMedicalPeriodPolicy: (data: Record<string, unknown>) =>
|
||||
post('/settings/medical-period/policies', data),
|
||||
/** 删除医疗期政策 */
|
||||
deleteMedicalPeriodPolicy: (id: string) =>
|
||||
del(`/settings/medical-period/policies/${id}`),
|
||||
}
|
||||
|
||||
// ========== 模板相关 ==========
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { toast } from 'sonner'
|
||||
|
||||
/**
|
||||
* 复制文本到剪贴板,带 execCommand fallback
|
||||
*/
|
||||
export async function copyToClipboard(text: string, successMsg = '已复制') {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
toast.success(successMsg)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// fall through to fallback
|
||||
}
|
||||
|
||||
// fallback: execCommand('copy') + hidden textarea
|
||||
try {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
textarea.style.left = '-9999px'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.focus()
|
||||
textarea.select()
|
||||
const ok = document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
if (ok) {
|
||||
toast.success(successMsg)
|
||||
} else {
|
||||
toast.error('复制失败,请手动复制')
|
||||
}
|
||||
} catch {
|
||||
toast.error('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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'
|
||||
import { rosterApi, employeeApi, terminationApi } from '../lib/api-services'
|
||||
import { copyToClipboard } from '../lib/clipboard'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useDebouncedValue } from '../hooks/useDebouncedValue'
|
||||
import Card from '../components/ui/Card'
|
||||
@@ -522,7 +523,7 @@ export default function Roster() {
|
||||
<div className="text-gray-400 text-xs font-mono cursor-pointer hover:text-primary transition-colors" title="点击复制完整身份证号" onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
if (e.idCardNumber) {
|
||||
navigator.clipboard.writeText(e.idCardNumber).then(() => toast.success('已复制身份证号')).catch(() => toast.error('复制失败'))
|
||||
copyToClipboard(e.idCardNumber, '已复制身份证号')
|
||||
}
|
||||
}}>{e.idCardMasked || '—'}</div>
|
||||
</td>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle, ClipboardList, LayoutGrid, PenTool } from 'lucide-react'
|
||||
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle, ClipboardList, LayoutGrid, PenTool, HeartPulse, Trash2 } from 'lucide-react'
|
||||
import { settingsApi, notificationsApi } from '../lib/api-services'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { getPageSize, setPageSize as setGlobalPageSize } from '../lib/pageSize'
|
||||
@@ -14,7 +14,7 @@ import { useConfirm } from '../hooks/useConfirm'
|
||||
|
||||
export default function Settings() {
|
||||
const queryClient = useQueryClient()
|
||||
const [activeSection, setActiveSection] = useState<'org' | 'users' | 'plan' | 'notifications' | 'retirement' | 'import' | 'export'>('org')
|
||||
const [activeSection, setActiveSection] = useState<'org' | 'users' | 'plan' | 'notifications' | 'retirement' | 'medical' | 'import' | 'export'>('org')
|
||||
|
||||
const { data: orgData } = useQuery<any>({
|
||||
queryKey: ['org-settings'],
|
||||
@@ -41,6 +41,7 @@ export default function Settings() {
|
||||
{ key: 'plan' as const, label: '套餐', icon: CreditCard },
|
||||
{ key: 'notifications' as const, label: '通知设置', icon: Bell },
|
||||
{ key: 'retirement' as const, label: '退休提醒', icon: Clock },
|
||||
{ key: 'medical' as const, label: '医疗期政策', icon: HeartPulse },
|
||||
{ key: 'import' as const, label: '数据导入', icon: FileSpreadsheet },
|
||||
{ key: 'export' as const, label: '数据导出', icon: Download },
|
||||
]
|
||||
@@ -82,6 +83,7 @@ export default function Settings() {
|
||||
{activeSection === 'retirement' && (
|
||||
<RetirementSettings orgData={orgData} onSave={(data) => updateOrgMutation.mutate(data)} />
|
||||
)}
|
||||
{activeSection === 'medical' && <MedicalPeriodSettings />}
|
||||
{activeSection === 'import' && <ImportSettings />}
|
||||
{activeSection === 'export' && <ExportSettings />}
|
||||
</div>
|
||||
@@ -1387,3 +1389,186 @@ function MonthlyImport() {
|
||||
)
|
||||
}
|
||||
|
||||
function MedicalPeriodSettings() {
|
||||
const queryClient = useQueryClient()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editPolicy, setEditPolicy] = useState<any>(null)
|
||||
|
||||
const { data: policies = [], isLoading } = useQuery<any[]>({
|
||||
queryKey: ['medical-period-policies'],
|
||||
queryFn: () => settingsApi.medicalPeriodPolicies(),
|
||||
})
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (data: any) => settingsApi.saveMedicalPeriodPolicy(data),
|
||||
onSuccess: () => {
|
||||
toast.success('政策已保存')
|
||||
queryClient.invalidateQueries({ queryKey: ['medical-period-policies'] })
|
||||
setShowForm(false)
|
||||
setEditPolicy(null)
|
||||
},
|
||||
onError: () => toast.error('保存失败'),
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: string) => settingsApi.deleteMedicalPeriodPolicy(id),
|
||||
onSuccess: () => {
|
||||
toast.success('已删除')
|
||||
queryClient.invalidateQueries({ queryKey: ['medical-period-policies'] })
|
||||
},
|
||||
onError: () => toast.error('删除失败'),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium">医疗期政策管理</h2>
|
||||
<p className="text-xs text-gray-500 mt-0.5">配置各地医疗期分档规则,计算器将根据所选地区自动匹配</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => { setEditPolicy(null); setShowForm(true) }}>
|
||||
<Plus className="w-4 h-4 mr-1" />新增政策
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-center text-gray-400 text-sm">加载中...</div>
|
||||
) : policies.length === 0 ? (
|
||||
<div className="py-8 text-center text-gray-400 text-sm">暂无政策</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{policies.map((p: any) => (
|
||||
<div key={p.id} className="border rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{p.region}</span>
|
||||
{p.isDefault && <span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary">默认</span>}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => { setEditPolicy(p); setShowForm(true) }} className="text-xs text-primary hover:underline">编辑</button>
|
||||
{!p.isDefault && (
|
||||
<button onClick={() => { if (confirm(`确认删除「${p.region}」政策?`)) deleteMut.mutate(p.id) }} className="p-1 hover:bg-gray-100 rounded">
|
||||
<Trash2 className="w-3.5 h-3.5 text-red-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mb-2">{p.legalBasis}</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-400">
|
||||
<th className="py-1 pr-3 font-medium">工作年限</th>
|
||||
<th className="py-1 pr-3 font-medium">医疗期</th>
|
||||
<th className="py-1 pr-3 font-medium">累计周期</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{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 (
|
||||
<tr key={idx}>
|
||||
<td className="py-1 pr-3">{isLast ? `${prevMax} 年以上` : `${prevMax}-${rule.maxYears} 年`}</td>
|
||||
<td className="py-1 pr-3">{rule.months} 个月</td>
|
||||
<td className="py-1 pr-3">{rule.cycleMonths} 个月</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{showForm && (
|
||||
<MedicalPolicyForm
|
||||
policy={editPolicy}
|
||||
onSave={(data) => saveMut.mutate(data)}
|
||||
onClose={() => { setShowForm(false); setEditPolicy(null) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<any[]>(
|
||||
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 (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-medium">{policy ? '编辑政策' : '新增政策'}</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">✕</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>地区名称</Label>
|
||||
<Input value={region} onChange={(e) => setRegion(e.target.value)} placeholder="如:广东" disabled={!!policy?.isDefault} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>法律依据</Label>
|
||||
<Input value={legalBasis} onChange={(e) => setLegalBasis(e.target.value)} placeholder="如:《广东省...》" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>分档规则</Label>
|
||||
<div className="space-y-2">
|
||||
{rules.map((rule, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 whitespace-nowrap">工龄 <</span>
|
||||
<input type="number" value={rule.maxYears} onChange={(e) => updateRule(idx, 'maxYears', parseInt(e.target.value) || 0)} className="w-16 px-2 py-1 text-xs border rounded" />
|
||||
<span className="text-xs text-gray-500">年 →</span>
|
||||
<input type="number" value={rule.months} onChange={(e) => updateRule(idx, 'months', parseInt(e.target.value) || 0)} className="w-16 px-2 py-1 text-xs border rounded" />
|
||||
<span className="text-xs text-gray-500">个月,周期</span>
|
||||
<input type="number" value={rule.cycleMonths} onChange={(e) => updateRule(idx, 'cycleMonths', parseInt(e.target.value) || 0)} className="w-16 px-2 py-1 text-xs border rounded" />
|
||||
<span className="text-xs text-gray-500">个月</span>
|
||||
{rules.length > 1 && (
|
||||
<button onClick={() => removeRule(idx)} className="p-1 hover:bg-gray-100 rounded">
|
||||
<Trash2 className="w-3 h-3 text-red-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button onClick={addRule} className="text-xs text-primary hover:underline">+ 添加分档</button>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} disabled={!!policy?.isDefault} />
|
||||
<span>设为默认政策</span>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={onClose}>取消</Button>
|
||||
<Button onClick={handleSave}>保存</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<any, any, { city: string; effectiveFrom: string; type: 'social' | 'housing' }>({
|
||||
|
||||
@@ -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)
|
||||
}}
|
||||
|
||||
@@ -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<string>('')
|
||||
const [filterType, setFilterType] = useState<string>('')
|
||||
const [selectedBatchId, setSelectedBatchId] = useState<string | null>(null)
|
||||
@@ -120,12 +122,14 @@ export function BatchManager() {
|
||||
})
|
||||
|
||||
const { data: batches, isLoading } = useQuery<any[]>({
|
||||
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() {
|
||||
<span className="text-xs text-gray-500">至</span>
|
||||
<Input type="month" value={monthTo} onChange={(e) => { setMonthTo(e.target.value); setMonth('') }} className="!w-36" />
|
||||
</div>
|
||||
{!monthFrom && !monthTo && (
|
||||
{!monthFrom && !monthTo && !dateFrom && !dateTo && (
|
||||
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="!w-36 shrink-0" placeholder="单月" />
|
||||
)}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className="text-xs text-gray-500">创建日期</span>
|
||||
<Input type="date" value={dateFrom} onChange={(e) => { setDateFrom(e.target.value); setMonth('') }} className="!w-36" placeholder="起始" />
|
||||
<span className="text-xs text-gray-500">~</span>
|
||||
<Input type="date" value={dateTo} onChange={(e) => { setDateTo(e.target.value); setMonth('') }} className="!w-36" placeholder="截止" />
|
||||
</div>
|
||||
<Select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} className="!w-28 shrink-0">
|
||||
<option value="">全部状态</option>
|
||||
<option value="DRAFT">草稿</option>
|
||||
@@ -230,8 +240,8 @@ export function BatchManager() {
|
||||
<option value="BONUS">年终奖</option>
|
||||
<option value="SEVERANCE">补偿金</option>
|
||||
</Select>
|
||||
{(monthFrom || monthTo || filterStatus || filterType) && (
|
||||
<button onClick={() => { setMonthFrom(''); setMonthTo(''); setFilterStatus(''); setFilterType(''); setMonth(new Date().toISOString().slice(0, 7)) }} className="text-xs text-gray-500 hover:text-primary shrink-0">
|
||||
{(monthFrom || monthTo || dateFrom || dateTo || filterStatus || filterType) && (
|
||||
<button onClick={() => { setMonthFrom(''); setMonthTo(''); setDateFrom(''); setDateTo(''); setFilterStatus(''); setFilterType(''); setMonth(new Date().toISOString().slice(0, 7)) }} className="text-xs text-gray-500 hover:text-primary shrink-0">
|
||||
清除筛选
|
||||
</button>
|
||||
)}
|
||||
@@ -327,6 +337,7 @@ export function BatchManager() {
|
||||
<th className="py-2 px-3 text-right">公积金合计</th>
|
||||
<th className="py-2 px-3 text-right">个税合计</th>
|
||||
<th className="py-2 px-3 text-right">实发合计</th>
|
||||
<th className="py-2 px-3">创建时间</th>
|
||||
<th className="py-2 px-3">状态</th>
|
||||
<th className="py-2 px-3 text-right">操作</th>
|
||||
</tr>
|
||||
@@ -376,6 +387,7 @@ export function BatchManager() {
|
||||
<td className="py-2.5 px-3 text-right text-sm text-cyan-600">¥{fmt((batch.totalHousingOrg || 0) + (batch.totalHousingEmp || 0))}</td>
|
||||
<td className="py-2.5 px-3 text-right text-sm text-danger">¥{fmt(batch.totalTax)}</td>
|
||||
<td className="py-2.5 px-3 text-right text-sm font-bold text-safe">¥{fmt(batch.totalNetPay)}</td>
|
||||
<td className="py-2.5 px-3 text-xs text-gray-500">{batch.createdAt ? new Date(batch.createdAt).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '-'}</td>
|
||||
<td className="py-2.5 px-3">
|
||||
{batch.status === 'ARCHIVED' ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-green-50 text-safe">
|
||||
|
||||
@@ -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, '已复制身份证号')
|
||||
}}
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
|
||||
@@ -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<HTMLInputElement>(null)
|
||||
const supplementFileRefs = useRef<Record<string, HTMLInputElement | null>>({})
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [previewName, setPreviewName] = useState<string>('附件')
|
||||
|
||||
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<HTMLInputElement>, 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<HTMLInputElement>) => {
|
||||
@@ -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 <span className="text-gray-400 ml-2">未上传</span>
|
||||
return (
|
||||
<div className="mt-1 space-y-1">
|
||||
{atts.length === 0 && <span className="text-gray-400 ml-2">未上传</span>}
|
||||
{atts.map((att, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between text-xs bg-gray-50 rounded px-2 py-1">
|
||||
<button onClick={() => setPreviewUrl(att.url)} className="text-primary hover:underline flex items-center gap-1 truncate">
|
||||
<button onClick={() => { setPreviewName(att.name); setPreviewUrl(att.url) }} className="text-primary hover:underline flex items-center gap-1 truncate">
|
||||
<Paperclip className="w-3 h-3 shrink-0" />{att.name}
|
||||
</button>
|
||||
<a href={att.url} download={att.name} className="text-gray-400 hover:text-primary ml-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-primary ml-2 shrink-0"
|
||||
title="下载附件"
|
||||
onClick={() => {
|
||||
const dataToBlobUrl = (dataUrl: string) => {
|
||||
try {
|
||||
const arr = dataUrl.split(',')
|
||||
const mime = arr[0].match(/:(.*?);/)?.[1] || 'application/octet-stream'
|
||||
const bstr = atob(arr[1])
|
||||
const u8 = new Uint8Array(bstr.length)
|
||||
for (let i = 0; i < bstr.length; i++) u8[i] = bstr.charCodeAt(i)
|
||||
return URL.createObjectURL(new Blob([u8], { type: mime }))
|
||||
} catch { return dataUrl }
|
||||
}
|
||||
const blobUrl = att.url.startsWith('data:') ? dataToBlobUrl(att.url) : att.url
|
||||
const a = document.createElement('a')
|
||||
a.href = blobUrl
|
||||
a.download = att.name
|
||||
a.click()
|
||||
if (blobUrl !== att.url) URL.revokeObjectURL(blobUrl)
|
||||
}}
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<input id={`contract-file-${c.id}`} type="file" multiple className="hidden" onChange={(e) => handleSupplementUpload(e, c.id, atts)} />
|
||||
<button type="button" onClick={() => document.getElementById(`contract-file-${c.id}`)?.click()} disabled={uploadAttachmentMutation.isPending} className="inline-flex items-center justify-center font-medium rounded-md transition-colors bg-gray-100 text-gray-700 hover:bg-gray-200 px-3 py-1.5 text-xs">
|
||||
<Paperclip className="w-3 h-3 mr-1" />{atts.length > 0 ? '补充上传' : '上传附件'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
@@ -298,9 +378,9 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => { if (await confirm({ title: '删除合同', message: '确定删除此合同记录?' })) deleteContractMutation.mutate(c.id) }}
|
||||
onClick={async () => { if (await confirm({ title: '作废合同', message: '确定作废此合同记录?作废后记录将保留但不再生效。' })) deleteContractMutation.mutate(c.id) }}
|
||||
className="text-gray-400 hover:text-danger shrink-0 ml-2 mt-1"
|
||||
title="删除合同"
|
||||
title="作废合同"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -332,9 +412,16 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b">
|
||||
<span className="text-sm font-medium">附件预览</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<a href={blobUrl} download="附件" className="text-xs text-primary hover:underline flex items-center gap-1">
|
||||
<button type="button" onClick={() => {
|
||||
const a = document.createElement('a')
|
||||
a.href = blobUrl
|
||||
a.download = previewName
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}} className="text-xs text-primary hover:underline flex items-center gap-1">
|
||||
<Download className="w-3 h-3" />下载
|
||||
</a>
|
||||
</button>
|
||||
<button onClick={() => { if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -349,9 +436,16 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
|
||||
<div className="text-center space-y-3">
|
||||
<FileText className="w-12 h-12 text-gray-300 mx-auto" />
|
||||
<p className="text-sm text-gray-500">此文件格式不支持在线预览</p>
|
||||
<a href={blobUrl} download="附件" className="inline-flex items-center gap-1 text-sm text-primary hover:underline">
|
||||
<button type="button" onClick={() => {
|
||||
const a = document.createElement('a')
|
||||
a.href = blobUrl
|
||||
a.download = previewName
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}} className="inline-flex items-center gap-1 text-sm text-primary hover:underline">
|
||||
<Download className="w-4 h-4" />点击下载查看
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 && <span className="text-gray-400">确认方式:{r.ackMethod === 'SIGN' ? '签字' : r.ackMethod === 'ELECTRONIC' ? '电子' : '拒绝'}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => deleteMutation.mutate(r.id)} className="text-xs text-gray-300 hover:text-danger shrink-0">删除</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{r.employeeAck && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const res = await fetch(`/api/v1/roster/${employeeId}/disciplinary/${r.id}/certificate`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
if (!res.ok) throw new Error('导出失败')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `违纪确认证明_${r.violationDate?.toString().slice(0, 10)}.doc`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
toast.error('下载失败')
|
||||
}
|
||||
}}
|
||||
className="text-xs text-primary hover:underline flex items-center gap-1 shrink-0"
|
||||
>
|
||||
<Download className="w-3 h-3" />下载确认证明
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => deleteMutation.mutate(r.id)} className="text-xs text-gray-300 hover:text-danger shrink-0">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -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() {
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<div className="flex gap-1">
|
||||
{r.employeeAck && (
|
||||
<button
|
||||
title="下载违纪确认证明"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const { useAuthStore } = await import('../../store/authStore')
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const res = await fetch(`/api/v1/roster/${r.employeeId}/disciplinary/${r.id}/certificate`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
if (!res.ok) throw new Error('导出失败')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
const cd = res.headers.get('content-disposition') || ''
|
||||
const fname = cd.match(/filename\*=UTF-8''(.+)/)?.[1] || cd.match(/filename="(.+?)"/)?.[1] || '违纪确认证明.doc'
|
||||
a.download = decodeURIComponent(fname)
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { toast.error('下载失败') }
|
||||
}}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5 text-primary" />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setEditRecord(r)} className="p-1 hover:bg-gray-100 rounded">
|
||||
<Edit2 className="w-3.5 h-3.5 text-gray-500" />
|
||||
</button>
|
||||
|
||||
@@ -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<string, string> = { 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 (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -35,14 +48,21 @@ export default function PerformanceInfo({ employeeId, records }: { employeeId: s
|
||||
{showForm && (
|
||||
<Card>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div><Label>考核周期</Label><Input value={form.period} onChange={(e) => setForm({ ...form, period: e.target.value })} placeholder="如 2026-07 或 2026-Q3" /></div>
|
||||
<div><Label>考核得分</Label><Input type="number" value={form.score} onChange={(e) => setForm({ ...form, score: Number(e.target.value) })} /></div>
|
||||
<div><Label>等级</Label>
|
||||
<div><Label>考核类型</Label>
|
||||
<Select value={form.periodType} onChange={(e) => setForm({ ...form, periodType: e.target.value as any })}>
|
||||
<option value="MONTHLY">月度考核</option>
|
||||
<option value="QUARTERLY">季度考核</option>
|
||||
<option value="YEARLY">年度考核</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>考核周期</Label><Input value={form.period} onChange={(e) => setForm({ ...form, period: e.target.value })} placeholder={form.periodType === 'MONTHLY' ? '如 2026-07' : form.periodType === 'QUARTERLY' ? '如 2026-Q3' : '如 2026'} /></div>
|
||||
<div><Label>考核得分</Label><Input type="number" value={form.score} onChange={(e) => handleScoreChange(Number(e.target.value))} /></div>
|
||||
<div><Label>等级(由得分自动计算)</Label>
|
||||
<Select value={form.grade} onChange={(e) => setForm({ ...form, grade: e.target.value })}>
|
||||
<option value="A">A</option><option value="B">B</option><option value="C">C</option><option value="D">D</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>考核结果</Label>
|
||||
<div><Label>考核结果(由得分自动计算)</Label>
|
||||
<Select value={form.result} onChange={(e) => setForm({ ...form, result: e.target.value })}>
|
||||
{Object.entries(resultMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</Select>
|
||||
|
||||
@@ -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 (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
@@ -197,6 +210,16 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: {
|
||||
<button onClick={onClose}><X className="w-4 h-4 text-gray-400" /></button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{!record && (
|
||||
<div>
|
||||
<Label>考核类型</Label>
|
||||
<Select value={form.periodType} onChange={(e) => setForm({ ...form, periodType: e.target.value })}>
|
||||
<option value="MONTHLY">月度考核</option>
|
||||
<option value="QUARTERLY">季度考核</option>
|
||||
<option value="YEARLY">年度考核</option>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{!record && (
|
||||
<div>
|
||||
<Label>员工</Label>
|
||||
@@ -210,15 +233,15 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: {
|
||||
)}
|
||||
<div>
|
||||
<Label>考核周期</Label>
|
||||
<Input type="month" value={form.period} onChange={(e) => setForm({ ...form, period: e.target.value })} />
|
||||
<Input type={form.periodType === 'YEARLY' ? 'number' : 'month'} value={form.period} onChange={(e) => setForm({ ...form, period: e.target.value })} placeholder={form.periodType === 'YEARLY' ? '如 2026' : undefined} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>得分</Label>
|
||||
<Input type="number" min={0} max={100} value={form.score} onChange={(e) => setForm({ ...form, score: Number(e.target.value) })} />
|
||||
<Input type="number" min={0} max={100} value={form.score} onChange={(e) => handleScoreChange(Number(e.target.value))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>等级</Label>
|
||||
<Label>等级(自动计算)</Label>
|
||||
<Select value={form.grade} onChange={(e) => setForm({ ...form, grade: e.target.value })}>
|
||||
<option value="A">A</option>
|
||||
<option value="B">B</option>
|
||||
@@ -228,7 +251,7 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>考核结果</Label>
|
||||
<Label>考核结果(自动计算)</Label>
|
||||
<Select value={form.result} onChange={(e) => setForm({ ...form, result: e.target.value })}>
|
||||
<option value="EXCELLENT">优秀</option>
|
||||
<option value="QUALIFIED">合格</option>
|
||||
|
||||
@@ -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<MedicalPeriodResult | null>(null)
|
||||
|
||||
const { data: policies = [] } = useQuery<MedicalPeriodPolicy[]>({
|
||||
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() {
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">所在地区</label>
|
||||
<select
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value as 'national' | 'shanghai')}
|
||||
value={selectedPolicy?.id || ''}
|
||||
onChange={(e) => setSelectedPolicyId(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="national">全国(通用规定)</option>
|
||||
<option value="shanghai">上海(特殊规定)</option>
|
||||
{policies.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.region}{p.isDefault ? '(默认)' : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -252,28 +228,36 @@ export default function MedicalPeriodCalculator() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 工龄分档表 */}
|
||||
<Card>
|
||||
<h2 className="text-sm font-medium mb-2">医疗期分档表(全国通用)</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<th className="py-2 pr-3">工作年限</th>
|
||||
<th className="py-2 pr-3">医疗期</th>
|
||||
<th className="py-2 pr-3">累计周期</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
<tr><td className="py-2 pr-3">不满 5 年</td><td className="py-2 pr-3">3 个月</td><td className="py-2 pr-3">6 个月</td></tr>
|
||||
<tr><td className="py-2 pr-3">5-10 年</td><td className="py-2 pr-3">6 个月</td><td className="py-2 pr-3">12 个月</td></tr>
|
||||
<tr><td className="py-2 pr-3">10-15 年</td><td className="py-2 pr-3">9 个月</td><td className="py-2 pr-3">15 个月</td></tr>
|
||||
<tr><td className="py-2 pr-3">15-20 年</td><td className="py-2 pr-3">12 个月</td><td className="py-2 pr-3">18 个月</td></tr>
|
||||
<tr><td className="py-2 pr-3">20 年以上</td><td className="py-2 pr-3">24 个月</td><td className="py-2 pr-3">30 个月</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
{/* 当前政策分档表 */}
|
||||
{selectedPolicy && (
|
||||
<Card>
|
||||
<h2 className="text-sm font-medium mb-2">医疗期分档表({selectedPolicy.region})</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<th className="py-2 pr-3">工作年限</th>
|
||||
<th className="py-2 pr-3">医疗期</th>
|
||||
<th className="py-2 pr-3">累计周期</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{selectedPolicy.rules.map((rule, idx) => {
|
||||
const prevMax = idx > 0 ? selectedPolicy.rules[idx - 1].maxYears : 0
|
||||
const isLast = idx === selectedPolicy.rules.length - 1
|
||||
return (
|
||||
<tr key={idx}>
|
||||
<td className="py-2 pr-3">{isLast ? `${prevMax} 年以上` : `${prevMax}-${rule.maxYears} 年`}</td>
|
||||
<td className="py-2 pr-3">{rule.months} 个月</td>
|
||||
<td className="py-2 pr-3">{rule.cycleMonths} 个月</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user