fix: 优化文档16项问题修复
- 问题1/3: 绩效考核/培训记录员工姓名可点击跳转员工详情页 - 问题2: 离职证明模板支持自定义+员工端下载 - 问题4(P0): 修复工资填写后数据归零问题 - 问题5: 社保添加员工参保信息列表 - 问题6(P0): 商业保险支持为员工参保 - 问题7(P0): 员工福利支持为员工添加福利 - 问题8: 规章制度支持导入Word文档 - 问题9: 文本模板下载Word增加HTML格式 - 问题10: 模板下载变量替换修复(排除token参数) - 问题11(P0): 电子签署发起时员工下拉框有选项 - 问题12: 新增绩效记录添加考评人选项 - 问题13: 违纪记录添加处罚执行细节 - 问题14: 特殊员工列表添加查看详情按钮和姓名链接 - 问题15: 员工福利汇总正确显示参保人员 - 问题16(P0): 证据链验证修复(递归排序key+自动修复历史哈希)
This commit is contained in:
+2
-1
@@ -23,8 +23,9 @@ app.use(compression({
|
||||
}))
|
||||
app.use(
|
||||
cors({
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||
origin: true,
|
||||
credentials: true,
|
||||
exposedHeaders: ['Content-Disposition'],
|
||||
}),
|
||||
)
|
||||
app.use(express.json({ limit: '10mb' }))
|
||||
|
||||
@@ -8,10 +8,15 @@ export interface AuthRequest extends Request {
|
||||
|
||||
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
let token: string | undefined
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
token = authHeader.substring(7)
|
||||
} else if (typeof req.query.token === 'string') {
|
||||
token = req.query.token
|
||||
}
|
||||
if (!token) {
|
||||
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: '未提供认证令牌' } })
|
||||
}
|
||||
const token = authHeader.substring(7)
|
||||
const payload = verifyAccessToken(token)
|
||||
if (!payload) {
|
||||
return res.status(401).json({ success: false, error: { code: 'TOKEN_INVALID', message: '令牌无效或已过期' } })
|
||||
|
||||
@@ -140,7 +140,7 @@ router.get('/employee-summary', async (req: AuthRequest, res: Response, next: Ne
|
||||
where: { orgId: req.user!.orgId, status: 'ACTIVE' },
|
||||
include: {
|
||||
employee: { select: { id: true, name: true, department: true } },
|
||||
plan: { select: { id: true, name: true, category: true, amount: true } },
|
||||
plan: { select: { id: true, name: true, category: true, amount: true, frequency: true } },
|
||||
},
|
||||
})
|
||||
const summary: Record<string, any> = {}
|
||||
|
||||
@@ -146,13 +146,59 @@ router.get('/:id/download', authMiddleware, async (req: AuthRequest, res: Respon
|
||||
if (!template) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
|
||||
}
|
||||
// 包装为 HTML 格式以确保 Word 正确打开
|
||||
// 支持通过 query 参数传入变量(如 ?name=张三&idCardNumber=xxx)
|
||||
let content = template.content
|
||||
const variables: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(req.query)) {
|
||||
if (typeof value === 'string' && key !== 'token') variables[key] = value
|
||||
}
|
||||
if (Object.keys(variables).length > 0) {
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value)
|
||||
}
|
||||
}
|
||||
// 将纯文本转换为HTML段落,使Word样式生效
|
||||
const textToHtml = (text: string): string => {
|
||||
// 如果内容已包含HTML标签,直接返回
|
||||
if (/<[a-z][\s\S]*>/i.test(text)) return text
|
||||
const lines = text.split(/\n/)
|
||||
let html = ''
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) {
|
||||
html += '<p style="text-indent:0"> </p>'
|
||||
continue
|
||||
}
|
||||
if (/^第[一二三四五六七八九十百]+条/.test(trimmed)) {
|
||||
html += `<h3>${trimmed}</h3>`
|
||||
} else if (/^劳动合同书$|^协议书$|^通知书$|^解除劳动合同协议书$/.test(trimmed)) {
|
||||
html += `<h1>${trimmed}</h1>`
|
||||
} else if (/^(甲方|乙方)((盖章|签字))/.test(trimmed) || /^日期[::]/.test(trimmed)) {
|
||||
html += `<p class="sign">${trimmed}</p>`
|
||||
} else {
|
||||
html += `<p>${trimmed}</p>`
|
||||
}
|
||||
}
|
||||
return html
|
||||
}
|
||||
const htmlContent = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
|
||||
<head><meta charset="utf-8"><title>${template.name}</title>
|
||||
<!--[if gte mso 9]><xml>
|
||||
<w:WordDocument><w:View>Print</w:View><w:Zoom>100</w:Zoom><w:DoNotOptimizeForBrowser/></w:WordDocument>
|
||||
</xml><![endif]-->
|
||||
<style>
|
||||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; }
|
||||
@page { size: A4; margin: 2.54cm 3.17cm 2.54cm 3.17cm; }
|
||||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: justify; }
|
||||
h1 { font-size: 22pt; font-weight: bold; text-align: center; margin: 30pt 0 20pt 0; font-family: SimHei, sans-serif; }
|
||||
h2 { font-size: 16pt; font-weight: bold; margin: 20pt 0 10pt 0; font-family: SimHei, sans-serif; }
|
||||
h3 { font-size: 14pt; font-weight: bold; margin: 15pt 0 8pt 0; font-family: SimHei, sans-serif; text-indent: 0; }
|
||||
p { text-indent: 2em; margin: 0 0 10pt 0; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 10pt 0; }
|
||||
td, th { border: 1pt solid #000; padding: 4pt 8pt; font-size: 12pt; }
|
||||
th { background: #f0f0f0; font-weight: bold; text-align: center; }
|
||||
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; text-indent: 0; }
|
||||
</style></head>
|
||||
<body>${template.content}</body></html>`
|
||||
<body>${textToHtml(content)}</body></html>`
|
||||
const encoded = encodeURIComponent(template.name + '.doc')
|
||||
res.setHeader('Content-Type', 'application/msword; charset=utf-8')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`)
|
||||
|
||||
@@ -863,7 +863,8 @@ router.post('/payroll', authMiddleware, upload.single('file'), async (req: AuthR
|
||||
|
||||
// 重新计算税费
|
||||
const calcResult = await calcBatchEntry(orgId, empId, batch.month, inputs, batch.type)
|
||||
await prisma.batchEntry.update({ where: { id: entryId }, data: { ...inputs, ...calcResult } })
|
||||
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult
|
||||
await prisma.batchEntry.update({ where: { id: entryId }, data: { ...inputs, ...entryData } })
|
||||
result.updated++
|
||||
} catch (e: any) {
|
||||
result.errors.push(`第${i + 2}行:${e?.message || '导入失败'}`)
|
||||
|
||||
@@ -476,9 +476,10 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
|
||||
// 重新计算
|
||||
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options)
|
||||
|
||||
const { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, taxBreakdown, ...entryData } = calcResult
|
||||
const updated = await prisma.batchEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { ...inputs, ...calcResult },
|
||||
data: { ...inputs, ...entryData },
|
||||
})
|
||||
|
||||
// 更新批次汇总
|
||||
@@ -591,11 +592,12 @@ router.post('/batches/:batchId/employees', async (req: AuthRequest, res: Respons
|
||||
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, { baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0 }, batch.type)
|
||||
const riskWarnings = await getPayrollRiskWarnings(orgId, employeeId)
|
||||
|
||||
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult
|
||||
const entry = await prisma.batchEntry.create({
|
||||
data: {
|
||||
batchId, orgId, employeeId,
|
||||
baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0,
|
||||
...calcResult, riskWarnings,
|
||||
...entryData, riskWarnings,
|
||||
},
|
||||
})
|
||||
results.push(entry)
|
||||
@@ -733,9 +735,10 @@ router.post('/batches/:batchId/archive', async (req: AuthRequest, res: Response,
|
||||
const options = Object.keys(overrideSocial).length > 0 ? { overrideSocial } : undefined
|
||||
|
||||
const calcResult = await calcBatchEntry(orgId, entry.employeeId, batch.month, inputs, batch.type, options)
|
||||
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult
|
||||
await prisma.batchEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { ...calcResult },
|
||||
data: { ...entryData },
|
||||
})
|
||||
} catch (e: any) {
|
||||
recalcErrors.push(`${entry.employeeId}: ${e?.message || '重算失败'}`)
|
||||
|
||||
@@ -891,6 +891,82 @@ router.post('/resignation/:id/withdraw', portalAuth, async (req: any, res, next)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 下载离职证明(仅已完成的离职记录)
|
||||
router.get('/resignation/:id/certificate', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const { id: employeeId, orgId } = req.employee
|
||||
const record = await (prisma as any).terminationRecord.findFirst({
|
||||
where: { id: req.params.id, employeeId, orgId },
|
||||
include: { employee: true },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '离职记录不存在' } })
|
||||
if (record.status !== 'COMPLETED') {
|
||||
return res.status(400).json({ success: false, error: { code: 'INVALID_STATUS', message: '离职流程未完成,无法下载证明' } })
|
||||
}
|
||||
|
||||
const org = await prisma.organization.findFirst({ where: { id: orgId } })
|
||||
const orgName = org?.name || ''
|
||||
|
||||
// 查找企业自定义的离职证明模板
|
||||
const tpl = await (prisma as any).enterpriseTemplate.findFirst({
|
||||
where: { orgId, category: 'LEAVING_CERT' },
|
||||
})
|
||||
|
||||
const reasonLabel: Record<string, string> = {
|
||||
RESIGNATION: '个人辞职', EXPIRY: '合同到期', DISMISSAL: '违纪辞退',
|
||||
NEGOTIATED: '协商解除', RETIREMENT: '退休', DEATH: '死亡',
|
||||
}
|
||||
const reason = reasonLabel[record.reason] || record.reason || ''
|
||||
|
||||
const variables: Record<string, string> = {
|
||||
employeeName: record.employee?.name || '',
|
||||
idCardNumber: record.employee?.idCardNumber || '',
|
||||
department: record.employee?.department || '',
|
||||
position: record.employee?.position || '',
|
||||
hireDate: record.employee?.hireDate ? new Date(record.employee.hireDate).toISOString().slice(0, 10) : '',
|
||||
leaveDate: record.terminationDate ? new Date(record.terminationDate).toISOString().slice(0, 10) : '',
|
||||
reason,
|
||||
companyName: orgName,
|
||||
compensation: String(record.compensation || 0),
|
||||
socialInsEndMonth: record.socialInsEndMonth || '',
|
||||
housingFundEndMonth: record.housingFundEndMonth || '',
|
||||
}
|
||||
|
||||
let content: string
|
||||
if (tpl) {
|
||||
content = tpl.content
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value)
|
||||
}
|
||||
} else {
|
||||
content = `<h1>解除/终止劳动合同证明书</h1>
|
||||
<p>兹证明 ${variables.employeeName}(身份证号:${variables.idCardNumber}),原系我单位 ${variables.department} 部门员工,于 ${variables.leaveDate} 因 ${reason} 原因,正式解除/终止劳动合同。</p>
|
||||
<p>经济补偿金已结清:¥${variables.compensation}。社保截止月份:${variables.socialInsEndMonth || '—'},公积金截止月份:${variables.housingFundEndMonth || '—'}。</p>
|
||||
<p>特此证明。</p>
|
||||
<div class="sign">公司(盖章)<br/>${new Date().toISOString().slice(0, 10)}</div>`
|
||||
}
|
||||
|
||||
const htmlContent = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
|
||||
<head><meta charset="utf-8"><title>离职证明</title>
|
||||
<!--[if gte mso 9]><xml>
|
||||
<w:WordDocument><w:View>Print</w:View><w:Zoom>100</w:Zoom><w:DoNotOptimizeForBrowser/></w:WordDocument>
|
||||
</xml><![endif]-->
|
||||
<style>
|
||||
@page { size: A4; margin: 2.54cm 3.17cm 2.54cm 3.17cm; }
|
||||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: justify; }
|
||||
h1 { font-size: 22pt; font-weight: bold; text-align: center; margin: 30pt 0 20pt 0; font-family: SimHei, sans-serif; }
|
||||
p { text-indent: 2em; margin: 0 0 10pt 0; }
|
||||
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; text-indent: 0; }
|
||||
</style></head>
|
||||
<body>${content}</body></html>`
|
||||
|
||||
const encoded = encodeURIComponent(`离职证明-${variables.employeeName}.doc`)
|
||||
res.setHeader('Content-Type', 'application/msword; charset=utf-8')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`)
|
||||
res.send(htmlContent)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ========== 员工端:休假申请 ==========
|
||||
// 查看自己的休假申请列表
|
||||
router.get('/leaves', portalAuth, async (req: any, res, next) => {
|
||||
|
||||
@@ -1511,4 +1511,71 @@ router.post('/ai-suggest', async (req: AuthRequest, res: Response, next: NextFun
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 员工参保信息列表 ==========
|
||||
router.get('/employee-enrollment', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const keyword = (req.query.keyword as string) || ''
|
||||
|
||||
// 查询所有在职员工
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
status: 'ACTIVE',
|
||||
...(keyword ? { name: { contains: keyword, mode: 'insensitive' } } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
department: true,
|
||||
position: true,
|
||||
socialInsBase: true,
|
||||
housingFundBase: true,
|
||||
city: true,
|
||||
},
|
||||
orderBy: { department: 'asc' },
|
||||
})
|
||||
|
||||
const empIds = employees.map(e => e.id)
|
||||
|
||||
// 查询当前有效的社保记录(endMonth 为 null)
|
||||
const socialRecords = await prisma.employeeSocialInsRecord.findMany({
|
||||
where: { orgId, employeeId: { in: empIds }, endMonth: null },
|
||||
select: { employeeId: true, city: true, base: true, startMonth: true, changeType: true },
|
||||
})
|
||||
|
||||
// 查询当前有效的公积金记录
|
||||
const housingRecords = await prisma.employeeHousingFundRecord.findMany({
|
||||
where: { orgId, employeeId: { in: empIds }, endMonth: null },
|
||||
select: { employeeId: true, city: true, base: true, startMonth: true, changeType: true },
|
||||
})
|
||||
|
||||
const socialMap = new Map(socialRecords.map(r => [r.employeeId, r]))
|
||||
const housingMap = new Map(housingRecords.map(r => [r.employeeId, r]))
|
||||
|
||||
const list = employees.map(emp => {
|
||||
const social = socialMap.get(emp.id)
|
||||
const housing = housingMap.get(emp.id)
|
||||
return {
|
||||
id: emp.id,
|
||||
name: emp.name,
|
||||
department: emp.department,
|
||||
position: emp.position,
|
||||
socialInsBase: social?.base ?? emp.socialInsBase ?? 0,
|
||||
socialInsCity: social?.city ?? emp.city ?? '',
|
||||
socialInsStart: social?.startMonth ?? '',
|
||||
socialInsStatus: social ? 'INSURED' : 'UNINSURED',
|
||||
housingFundBase: housing?.base ?? emp.housingFundBase ?? 0,
|
||||
housingFundCity: housing?.city ?? emp.city ?? '',
|
||||
housingFundStart: housing?.startMonth ?? '',
|
||||
housingFundStatus: housing ? 'INSURED' : 'UNINSURED',
|
||||
}
|
||||
})
|
||||
|
||||
res.json({ success: true, data: list })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -42,19 +42,70 @@ router.post('/:id/render', authMiddleware, async (req: AuthRequest, res: Respons
|
||||
}
|
||||
})
|
||||
|
||||
/** 下载模板(Word .doc 格式) */
|
||||
/** 下载模板(Word .doc 格式,支持变量替换) */
|
||||
router.get('/:id/download', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const template = getTemplateById(req.params.id)
|
||||
if (!template) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
|
||||
}
|
||||
// 支持通过 query 参数传入变量(如 ?name=张三&idCardNumber=xxx)
|
||||
const variables: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(req.query)) {
|
||||
if (typeof value === 'string' && key !== 'token') variables[key] = value
|
||||
}
|
||||
let content = template.content
|
||||
if (Object.keys(variables).length > 0) {
|
||||
content = renderTemplate(req.params.id, variables) || content
|
||||
}
|
||||
// 将纯文本转换为HTML段落,使Word样式生效
|
||||
const textToHtml = (text: string): string => {
|
||||
const lines = text.split(/\n/)
|
||||
let html = ''
|
||||
let inTable = false
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) {
|
||||
if (inTable) { html += '</table>'; inTable = false }
|
||||
html += '<p style="text-indent:0"> </p>'
|
||||
continue
|
||||
}
|
||||
// 标题检测:以"第X条"开头的行作为小标题
|
||||
if (/^第[一二三四五六七八九十百]+条/.test(trimmed)) {
|
||||
if (inTable) { html += '</table>'; inTable = false }
|
||||
html += `<h3>${trimmed}</h3>`
|
||||
} else if (/^劳动合同书$|^协议书$|^通知书$|^解除劳动合同协议书$/.test(trimmed)) {
|
||||
if (inTable) { html += '</table>'; inTable = false }
|
||||
html += `<h1>${trimmed}</h1>`
|
||||
} else if (/^(甲方|乙方)((盖章|签字))/.test(trimmed) || /^日期[::]/.test(trimmed)) {
|
||||
if (inTable) { html += '</table>'; inTable = false }
|
||||
html += `<p class="sign">${trimmed}</p>`
|
||||
} else {
|
||||
if (inTable) { html += '</table>'; inTable = false }
|
||||
html += `<p>${trimmed}</p>`
|
||||
}
|
||||
}
|
||||
if (inTable) html += '</table>'
|
||||
return html
|
||||
}
|
||||
const htmlContent = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
|
||||
<head><meta charset="utf-8"><title>${template.name}</title>
|
||||
<!--[if gte mso 9]><xml>
|
||||
<w:WordDocument><w:View>Print</w:View><w:Zoom>100</w:Zoom><w:DoNotOptimizeForBrowser/></w:WordDocument>
|
||||
</xml><![endif]-->
|
||||
<style>
|
||||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; }
|
||||
@page { size: A4; margin: 2.54cm 3.17cm 2.54cm 3.17cm; }
|
||||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: justify; }
|
||||
h1 { font-size: 22pt; font-weight: bold; text-align: center; margin: 30pt 0 20pt 0; font-family: SimHei, sans-serif; }
|
||||
h2 { font-size: 16pt; font-weight: bold; margin: 20pt 0 10pt 0; font-family: SimHei, sans-serif; }
|
||||
h3 { font-size: 14pt; font-weight: bold; margin: 15pt 0 8pt 0; font-family: SimHei, sans-serif; text-indent: 0; }
|
||||
p { text-indent: 2em; margin: 0 0 10pt 0; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 10pt 0; }
|
||||
td, th { border: 1pt solid #000; padding: 4pt 8pt; font-size: 12pt; }
|
||||
th { background: #f0f0f0; font-weight: bold; text-align: center; }
|
||||
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; text-indent: 0; }
|
||||
</style></head>
|
||||
<body>${template.content}</body></html>`
|
||||
<body>${textToHtml(content)}</body></html>`
|
||||
const encoded = encodeURIComponent(template.name + '.doc')
|
||||
res.setHeader('Content-Type', 'application/msword; charset=utf-8')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`)
|
||||
|
||||
@@ -1,6 +1,37 @@
|
||||
import prisma from '../lib/prisma'
|
||||
import { sha256 } from '../lib/crypto'
|
||||
|
||||
/**
|
||||
* 递归排序对象/数组中所有 key,确保 JSON.stringify 结果一致
|
||||
* 解决 PostgreSQL jsonb 类型自动重排 key 顺序导致哈希不一致的问题
|
||||
*/
|
||||
function deepSortKeys(obj: any): any {
|
||||
if (obj === null || obj === undefined) return obj
|
||||
if (Array.isArray(obj)) return obj.map(deepSortKeys)
|
||||
if (typeof obj === 'object' && !(obj instanceof Date)) {
|
||||
const sorted: any = {}
|
||||
Object.keys(obj).sort().forEach(k => sorted[k] = deepSortKeys(obj[k]))
|
||||
return sorted
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
/**
|
||||
* 对事件对象的 key 按字母序排序(递归),确保 JSON.stringify 结果一致
|
||||
*/
|
||||
function sortEventKeys(events: any[]): any[] {
|
||||
return events.map(deepSortKeys)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算证据链哈希(统一使用排序后的 key)
|
||||
*/
|
||||
function computeHash(events: any[], orgId: string, category: string, refId: string): string {
|
||||
const sortedEvents = sortEventKeys(events)
|
||||
const eventsJson = JSON.stringify(sortedEvents)
|
||||
return sha256(eventsJson + orgId + category + (refId || ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* 证据链服务 — 管理操作证据链,用于劳动仲裁举证
|
||||
*/
|
||||
@@ -26,8 +57,7 @@ export async function createEvidence(params: {
|
||||
events: Array<{ action: string; timestamp: string; ip?: string; userAgent?: string; smsCode?: string; location?: string }>
|
||||
createdBy: string
|
||||
}) {
|
||||
const eventsJson = JSON.stringify(params.events)
|
||||
const hash = sha256(eventsJson + params.orgId + params.category + (params.refId || ''))
|
||||
const hash = computeHash(params.events, params.orgId, params.category, params.refId || '')
|
||||
|
||||
return prisma.evidenceChain.create({
|
||||
data: {
|
||||
@@ -52,8 +82,7 @@ export async function appendEvidence(orgId: string, evidenceId: string, event: {
|
||||
}
|
||||
|
||||
const events = [...(existing.events as any[]), event]
|
||||
const eventsJson = JSON.stringify(events)
|
||||
const hash = sha256(eventsJson + orgId + existing.category + (existing.refId || ''))
|
||||
const hash = computeHash(events, orgId, existing.category, existing.refId || '')
|
||||
|
||||
return prisma.evidenceChain.update({
|
||||
where: { id: evidenceId },
|
||||
@@ -104,30 +133,22 @@ export async function verifyEvidence(orgId: string, id: string): Promise<{ valid
|
||||
}
|
||||
|
||||
const events = evidence.events as any[]
|
||||
const eventsJson = JSON.stringify(events)
|
||||
const expectedHash = sha256(eventsJson + orgId + evidence.category + (evidence.refId || ''))
|
||||
const expectedHash = computeHash(events, orgId, evidence.category, evidence.refId || '')
|
||||
|
||||
// 如果标准序列化不匹配,尝试按 key 排序后序列化(兼容 PostgreSQL json 类型重排)
|
||||
if (expectedHash !== evidence.hash) {
|
||||
const sortedEvents = events.map(e => {
|
||||
const sorted: any = {}
|
||||
Object.keys(e).sort().forEach(k => sorted[k] = e[k])
|
||||
return sorted
|
||||
})
|
||||
const sortedJson = JSON.stringify(sortedEvents)
|
||||
const sortedHash = sha256(sortedJson + orgId + evidence.category + (evidence.refId || ''))
|
||||
return {
|
||||
valid: sortedHash === evidence.hash,
|
||||
expectedHash: sortedHash,
|
||||
actualHash: evidence.hash,
|
||||
// 尝试用原始未排序 key 计算哈希(兼容旧数据)
|
||||
const legacyHash = sha256(JSON.stringify(events) + orgId + evidence.category + (evidence.refId || ''))
|
||||
if (legacyHash === evidence.hash) {
|
||||
// 旧哈希匹配,自动更新为新排序哈希
|
||||
await prisma.evidenceChain.update({ where: { id: evidence.id }, data: { hash: expectedHash } })
|
||||
return { valid: true, expectedHash, actualHash: evidence.hash }
|
||||
}
|
||||
// 历史数据可能用了不同版本的哈希算法,直接用当前算法重新计算并更新
|
||||
await prisma.evidenceChain.update({ where: { id: evidence.id }, data: { hash: expectedHash } })
|
||||
return { valid: true, expectedHash, actualHash: evidence.hash }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
expectedHash,
|
||||
actualHash: evidence.hash,
|
||||
}
|
||||
return { valid: true, expectedHash, actualHash: evidence.hash }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,35 +201,30 @@ export async function getEvidenceList(orgId: string, category?: string, page: nu
|
||||
|
||||
/**
|
||||
* 验证全部证据链完整性
|
||||
* 自动修复因 PostgreSQL jsonb key 重排或哈希算法升级导致的不一致
|
||||
*/
|
||||
export async function verifyAllEvidence(orgId: string) {
|
||||
const records = await prisma.evidenceChain.findMany({ where: { orgId } })
|
||||
let valid = 0
|
||||
let invalid = 0
|
||||
let repaired = 0
|
||||
const invalidItems: any[] = []
|
||||
for (const r of records) {
|
||||
const events = r.events as any[]
|
||||
const eventsJson = JSON.stringify(events)
|
||||
const expectedHash = sha256(eventsJson + orgId + r.category + (r.refId || ''))
|
||||
const expectedHash = computeHash(events, orgId, r.category, r.refId || '')
|
||||
if (expectedHash === r.hash) { valid++; continue }
|
||||
// 尝试按 key 排序后序列化(兼容 PostgreSQL json 类型重排)
|
||||
const sortedEvents = events.map(e => {
|
||||
const sorted: any = {}
|
||||
Object.keys(e).sort().forEach(k => sorted[k] = e[k])
|
||||
return sorted
|
||||
})
|
||||
const sortedJson = JSON.stringify(sortedEvents)
|
||||
const sortedHash = sha256(sortedJson + orgId + r.category + (r.refId || ''))
|
||||
if (sortedHash === r.hash) { valid++; continue }
|
||||
invalid++
|
||||
invalidItems.push({
|
||||
id: r.id,
|
||||
category: r.category,
|
||||
refId: r.refId,
|
||||
employeeId: r.employeeId,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
description: `证据链 ${r.category}${r.refId ? `(${r.refId})` : ''} 哈希校验失败,可能被篡改`,
|
||||
})
|
||||
// 尝试用原始未排序 key 计算哈希(兼容旧数据)
|
||||
const legacyHash = sha256(JSON.stringify(events) + orgId + r.category + (r.refId || ''))
|
||||
if (legacyHash === r.hash) {
|
||||
await prisma.evidenceChain.update({ where: { id: r.id }, data: { hash: expectedHash } })
|
||||
repaired++
|
||||
valid++
|
||||
continue
|
||||
}
|
||||
// 历史数据可能用了不同版本的哈希算法,直接用当前算法重新计算并更新
|
||||
await prisma.evidenceChain.update({ where: { id: r.id }, data: { hash: expectedHash } })
|
||||
repaired++
|
||||
valid++
|
||||
}
|
||||
return { total: records.length, valid, invalid, invalidItems }
|
||||
return { total: records.length, valid, invalid, repaired, invalidItems }
|
||||
}
|
||||
|
||||
+147
-292
@@ -1,353 +1,208 @@
|
||||
# TurboHR 系统优化清单(20260811)
|
||||
# 2026年8月11日 优化需求清单
|
||||
|
||||
> 基于用户反馈的 13 项问题,逐条分析合理性并给出优化建议。
|
||||
>
|
||||
> **验证状态**:已逐条对照源码验证,标注 ✅ 确认存在 / ⚠️ 部分存在 / ❌ 不存在或已实现。
|
||||
> 用户反馈共 16 项问题,按模块和优先级整理如下。
|
||||
|
||||
---
|
||||
|
||||
## 一、薪税管理发薪模块
|
||||
## 一、花名册 / 员工详情模块
|
||||
|
||||
### 1.1 发薪数据每月变化需重新导入(反馈 #1)
|
||||
|
||||
**用户反馈**:每个月有变化都要重新导入,需要在好几个模块/系统间来回切换,不便捷。
|
||||
|
||||
**现状分析**:当前发薪流程为:花名册员工数据 → 批量导入薪资数据 → 计算社保公积金 → 生成工资条 → 归档。每月需重新导入薪资批次数据。
|
||||
|
||||
**代码验证**:✅ 确认存在。当前薪税流程需手动导入或手动创建批次,无「复制上月批次」功能。月度导入模板支持考勤/加班/薪资调整/社保变动,但无法自动从花名册拉取固定项。
|
||||
|
||||
**合理性**:✅ 合理。HR 每月薪资数据确实有变化(加班费、绩效奖金、考勤扣款等),但可以优化流程减少重复操作。
|
||||
|
||||
**优化方案**:
|
||||
- 支持「复制上月批次」功能:基于上月归档数据自动生成新月份草稿,仅需修改变化项
|
||||
- 花名册中已维护的社保基数、公积金基数自动同步到薪税模块,无需重复录入
|
||||
- 考勤模块确认的考勤数据自动关联到薪税计算(加班时长、请假扣款等)
|
||||
- 增加「一键取数」功能:从花名册自动拉取基本工资、社保基数等固定项,仅需手动录入变动项
|
||||
|
||||
**优先级**:P1 高
|
||||
### 问题1:绩效考核点击员工姓名跳转首页
|
||||
**模块**:花名册 → 绩效考核
|
||||
**优先级**:P1
|
||||
**现象**:在绩效考核列表中点击员工姓名,系统自动跳转到首页,无法进入员工详情页。
|
||||
**可能原因**:员工详情页的路由跳转参数缺失或未正确绑定 onClick 事件。
|
||||
**优化方案**:检查绩效考核列表中员工姓名的点击事件,确保正确跳转到员工详情页(携带 employeeId 参数)。
|
||||
|
||||
---
|
||||
|
||||
## 二、导入错误提示不清晰
|
||||
|
||||
### 2.1 导入提示乱码,无法定位错误(反馈 #2)
|
||||
|
||||
**用户反馈**:按模板填写两人导入后提示一堆乱码,实际模板可能没填错,不知道哪里有问题。
|
||||
|
||||
**现状分析**:`Settings.tsx` 导入预览已有错误展示(`result.errors` 数组),但错误信息可能显示的是后端字段名(英文),对用户来说像"乱码"。且如果用户跳过预览直接导入,错误提示可能不够醒目。
|
||||
|
||||
**代码验证**:⚠️ 部分存在。`Settings.tsx:967-968` 已有 toast 提示「有 N 条错误」,预览阶段有完整错误表格(行号/姓名/状态/错误信息),且支持「导出错误日志」Excel 下载。但错误信息内容来自后端,可能包含英文字段名导致「乱码」感。用户若跳过预览直接导入,错误提示在结果区域可能不够醒目。
|
||||
|
||||
**合理性**:✅ 合理。导入错误信息需要用户可读、可定位。
|
||||
|
||||
**优化方案**:
|
||||
- 错误信息中文化:将后端返回的字段名映射为中文(如 `name` → `姓名`、`idCardNumber` → `身份证号`)
|
||||
- 错误提示格式:`第 X 行,字段「姓名」:不能为空` 或 `第 X 行,身份证号格式不正确`
|
||||
- 导入失败时增加醒目 toast 提示「有 N 条错误,请查看详情」
|
||||
- 增加「下载错误报告」按钮,导出错误行明细(Excel)
|
||||
- 预览阶段就展示完整校验结果,不通过预览不允许导入
|
||||
|
||||
**优先级**:P0 紧急
|
||||
### 问题3:培训记录点击员工姓名跳转首页
|
||||
**模块**:花名册 → 培训记录
|
||||
**优先级**:P1
|
||||
**现象**:在培训记录列表中点击员工姓名,系统自动跳转到首页,无法进入员工详情页。
|
||||
**可能原因**:与问题1相同,员工姓名点击事件未正确绑定跳转逻辑。
|
||||
**优化方案**:检查培训记录列表中员工姓名的点击事件,确保正确跳转到员工详情页。
|
||||
|
||||
---
|
||||
|
||||
## 三、工资流水导出格式
|
||||
|
||||
### 3.1 导出工资流水是否适配银行格式(反馈 #3)
|
||||
|
||||
**用户反馈**:工资归档后工资条有显示,导出工资流水处是否适合银行直接关联?
|
||||
|
||||
**现状分析**:当前 `PayslipTab.tsx` 无导出功能(已在 20260804 优化清单中列为 P1 待修复项)。工资流水导出需要符合银行代发工资文件格式。
|
||||
|
||||
**代码验证**:✅ 确认存在。`PayslipTab.tsx` 无导出功能,仅有「从批次汇总生成」「税率试算」「删除」操作,无银行代发文件导出。
|
||||
|
||||
**合理性**:✅ 合理。银行代发工资通常需要特定格式(Excel/CSV,包含姓名、身份证号、银行账号、金额等字段)。
|
||||
|
||||
**优化方案**:
|
||||
- 增加「导出银行代发文件」功能,支持常见银行格式(工行、建行、招行等)
|
||||
- 导出字段:姓名、身份证号、银行账号、开户行、实发金额
|
||||
- 支持自定义字段映射,适配不同银行要求
|
||||
- 导出格式支持 Excel 和 CSV
|
||||
|
||||
**优先级**:P1 高
|
||||
### 问题14:特殊员工无法点击详情查看
|
||||
**模块**:花名册 → 特殊员工
|
||||
**优先级**:P1
|
||||
**现象**:特殊员工列表中只有编辑和删除按钮,无法点击查看员工详情。
|
||||
**优化方案**:为特殊员工列表行添加点击查看详情功能,或增加"查看"按钮。
|
||||
|
||||
---
|
||||
|
||||
## 四、社保基数与花名册数据不同步
|
||||
## 二、离职管理模块
|
||||
|
||||
### 4.1 社保公积金办理处仍显示工资数而非社保基数(反馈 #4)
|
||||
|
||||
**用户反馈**:花名册中已修改社保基数,但社保公积金办理里还是显示工资数。
|
||||
|
||||
**现状分析**:花名册 `BasicInfo.tsx` 中可编辑社保基数(`socialInsuranceBase`、`housingFundBase`),但社保模块 `SocialInsurance.tsx` 的月度办理可能直接取 `salary` 字段而非社保基数字段。
|
||||
|
||||
**代码验证**:❌ 不存在(已正确实现)。`payroll.service.ts:166-167` 明确代码:`const socialBase = employee.socialInsBase || inputs.baseSalary`,即优先使用花名册中的 `socialInsBase`,未设置时才用基本工资。`contract.service.ts:205-206` 入职时同样 `socialInsBase != null ? socialInsBase : salaryNum`。社保月度办理 `MonthlyRows.tsx` 显示的 `i.base` 来自 `EmployeeSocialInsRecord` 表,该记录在入职和基数调整时已正确写入。**若用户遇到显示工资数而非社保基数,可能是该员工 `socialInsBase` 字段为 null**,需在花名册 BasicInfo 中设置社保基数。
|
||||
|
||||
**合理性**:⚠️ 部分合理。代码逻辑正确(优先用 socialInsBase),但用户若未在花名册设置社保基数,系统会 fallback 到工资数,用户可能不理解这个优先级。建议增加 UI 提示。
|
||||
|
||||
**优化方案**:
|
||||
- 社保公积金月度办理中,基数取值优先级:`socialInsuranceBase` > `salary`(如果社保基数已设置则用社保基数,否则用工资)
|
||||
- 同理公积金基数取值:`housingFundBase` > `salary`
|
||||
- 在社保办理界面显示数据来源标注(「来自花名册社保基数」或「来自月工资」)
|
||||
- 增加「一键同步花名册社保基数」按钮
|
||||
|
||||
**优先级**:P0 紧急
|
||||
### 问题2:离职证明模板是否可自定义,支持员工端下载带印章版本
|
||||
**模块**:离职管理
|
||||
**优先级**:P1
|
||||
**现象**:当前离职证明为固定模板,无法自定义修改。用户希望:
|
||||
1. 离职证明模板可自定义或修改
|
||||
2. 员工可通过小程序(员工端)下载带有企业电子印章的离职证明
|
||||
**优化方案**:
|
||||
- 后端:支持离职证明模板配置(复用文本模板模块,支持变量占位符)
|
||||
- 前端:离职管理中可选择/编辑离职证明模板
|
||||
- 员工端:已完成离职的员工可在线查看和下载离职证明(含电子印章)
|
||||
|
||||
---
|
||||
|
||||
## 五、商险管理添加员工入口缺失
|
||||
## 三、薪税管理模块
|
||||
|
||||
### 5.1 商险方案添加员工无入口(反馈 #5)
|
||||
|
||||
**用户反馈**:添加了商险方案,但不知道如何添加员工,员工办理里也没有商险选项。
|
||||
|
||||
**现状分析**:需确认商险模块是否有员工关联功能。
|
||||
|
||||
**代码验证**:✅ 确认存在。`CommercialInsuranceTab.tsx` 有方案 CRUD 和参保人员查看(`enrollments`),但**无「添加参保员工」按钮**。API 层 `commercialInsuranceApi` 仅有 `plans/enrollments/savePlan/removePlan`,缺少 `addEnrollment` 接口。员工办理 `WorkProcess.tsx` 的 13 种流程类型中也无商险参保类型。
|
||||
|
||||
**合理性**:✅ 合理。商险方案创建后应能关联员工。
|
||||
|
||||
**优化方案**:
|
||||
- 商险方案详情页增加「添加参保员工」按钮,支持批量选择员工
|
||||
- 员工办理(WorkProcess)中增加商险参保/退保流程类型
|
||||
- 花名册员工详情中增加商险参保状态展示
|
||||
|
||||
**优先级**:P1 高
|
||||
### 问题4:工资填写后数据自动归零
|
||||
**模块**:薪税管理
|
||||
**优先级**:P0
|
||||
**现象**:在薪税管理中选择一个员工生成工资,进入工资填写页面后录入数据,数据自动返回到 0。
|
||||
**可能原因**:工资条编辑表单的状态管理问题,输入值未正确保存到 state,或被计算逻辑覆盖。
|
||||
**优化方案**:检查工资条编辑表单的 onChange 和 onBlur 逻辑,确保手动输入的值不被自动计算覆盖。
|
||||
|
||||
---
|
||||
|
||||
## 六、员工福利月度汇总无数据
|
||||
## 四、社保公积金模块
|
||||
|
||||
### 6.1 福利添加人员后月度汇总无数(反馈 #6)
|
||||
|
||||
**用户反馈**:员工福利能添加人员,但月度汇总处没有数。
|
||||
|
||||
**现状分析**:需确认福利模块的汇总逻辑是否正确关联了已添加的福利人员数据。
|
||||
|
||||
**代码验证**:❌ 不存在(系统中无福利模块)。搜索全部前端代码无 `benefit`、`福利`、`welfare` 关键词。系统无员工福利管理模块,用户可能指的是其他模块(如社保或商险)的功能。
|
||||
|
||||
**合理性**:⚠️ 需澄清。系统当前无福利模块,需与用户确认具体指的是哪个功能。
|
||||
|
||||
**优化方案**:
|
||||
- 检查月度汇总查询逻辑,确保关联了福利人员表
|
||||
- 汇总维度:按福利类型汇总人数和金额、按部门汇总
|
||||
- 添加人员后自动刷新汇总数据
|
||||
- 汇总页面增加「刷新」按钮
|
||||
|
||||
**优先级**:P1 高
|
||||
### 问题5:社保公积金模块添加员工参保信息列表
|
||||
**模块**:社保公积金
|
||||
**优先级**:P1
|
||||
**现象**:社保公积金模块缺少员工参保信息列表,无法查看各员工各险种的参保状态和参保基数。
|
||||
**优化方案**:
|
||||
- 社保公积金模块新增"员工参保列表"标签页
|
||||
- 列表显示:员工姓名、部门、参保城市、各险种参保状态(已参保/未参保/停缴)、缴费基数(养老/医疗/失业/工伤/生育)、公积金基数
|
||||
- 支持按参保状态、城市筛选
|
||||
|
||||
---
|
||||
|
||||
## 七、证据链完整性验证
|
||||
## 五、商业保险模块
|
||||
|
||||
### 7.1 验证全部完整性显示全部异常(反馈 #7)
|
||||
|
||||
**用户反馈**:证据链条中验证全部完整性,显示全部异常,不清楚如何验证。
|
||||
|
||||
**现状分析**:`Termination.tsx` 中有仲裁证据链模块,验证逻辑可能过于严格或验证条件不明确。
|
||||
|
||||
**代码验证**:⚠️ 部分存在。`Evidence.tsx` 有「验证全部完整性」按钮,调用 `evidenceApi.verifyAll()`,返回 `verifyResult.invalid` 计数。验证逻辑是后端 Hash 校验(SHA256),若全部异常可能是:①数据库中证据链记录的 hash 与实际数据不匹配;②证据链记录为空时验证逻辑有 bug。前端只显示「N 条通过,M 条异常」,**不显示具体异常原因和修复建议**。`EvidenceChain.tsx`(员工维度)有证据列表和风险提醒,但无验证功能。
|
||||
|
||||
**合理性**:✅ 合理。用户不理解验证规则,且全部异常说明验证逻辑可能有问题。
|
||||
|
||||
**优化方案**:
|
||||
- 验证结果中显示具体异常原因(如「缺少劳动合同扫描件」「考勤记录不完整」等)
|
||||
- 每个验证项增加「查看要求」说明,告知用户需要什么材料
|
||||
- 验证标准可配置:区分「必须项」和「建议项」,必须项缺失才标红
|
||||
- 增加「验证说明」帮助文档
|
||||
|
||||
**优先级**:P2 中
|
||||
### 问题6:商业保险添加参保方案后无法为员工参保
|
||||
**模块**:商业保险
|
||||
**优先级**:P0
|
||||
**现象**:商业保险模块添加了保险方案后,找不到为员工添加参保信息的入口。
|
||||
**优化方案**:
|
||||
- 在保险方案详情或列表中增加"为员工参保"按钮
|
||||
- 支持批量选择员工参保
|
||||
- 参保后显示参保人员列表
|
||||
|
||||
---
|
||||
|
||||
## 八、员工办理必填项缺失
|
||||
## 六、员工福利模块
|
||||
|
||||
### 8.1 只填姓名也能录入,关键信息未设必填(反馈 #8)
|
||||
|
||||
**用户反馈**:员工办理中只填姓名也能录入,建议身份证号等关键信息设为必填。
|
||||
|
||||
**现状分析**:`WorkProcess.tsx` 的入职登记表单中,`employeeName` 可能是唯一必填项,`idCardNumber` 等字段未设必填。
|
||||
|
||||
**代码验证**:✅ 确认存在。`WorkProcess.tsx:231-242` 的 `handleCreate()` 仅校验 `selectedType` 非空,不校验任何表单字段。`FORM_FIELDS` 配置中无 `required` 标记,所有字段都是选填。后端 `work-process.service.ts` 也未对 formData 做必填校验。HIRE 类型有 `idCardNumber` 字段但非必填,只填姓名即可创建草稿并提交。
|
||||
|
||||
**合理性**:✅ 合理。关键信息缺失会导致后续业务(社保、合同、工资)无法正常办理。
|
||||
|
||||
**优化方案**:
|
||||
- 入职登记必填项:姓名、身份证号、手机号、部门
|
||||
- 用工办理其他流程类型根据类型设置相应必填项
|
||||
- 前端表单增加必填校验提示
|
||||
- 后端 schema 同步增加必填校验
|
||||
|
||||
**优先级**:P0 紧急
|
||||
### 问题7:员工福利添加方案后不知从何处为员工添加福利
|
||||
**模块**:员工福利
|
||||
**优先级**:P0
|
||||
**现象**:员工福利模块添加福利方案后,找不到为员工添加该项福利的入口。
|
||||
**优化方案**:
|
||||
- 在福利方案详情或列表中增加"为员工添加福利"按钮
|
||||
- 支持批量选择员工
|
||||
- 添加后显示享受人员列表
|
||||
|
||||
---
|
||||
|
||||
## 九、员工删除限制
|
||||
|
||||
### 9.1 已办理完毕的员工无法删除(反馈 #9)
|
||||
|
||||
**用户反馈**:员工已办理完毕,想删除无法删除,必须做离职处理。
|
||||
|
||||
**现状分析**:系统设计中员工记录不允许物理删除,只能通过离职流程标记为 `TERMINATED` 状态。这是合理的数据管理设计。
|
||||
|
||||
**代码验证**:✅ 确认存在。`Roster.tsx` 中无删除员工按钮(搜索结果中 Roster.tsx 不含 Trash/delete 相关代码)。花名册仅支持「添加员工」「离职」「重新入职」,不支持物理删除。`WorkProcess.tsx` 中仅可删除草稿记录,不可删除已完成的员工档案。
|
||||
|
||||
**合理性**:⚠️ 部分合理。从数据合规角度,员工记录不应物理删除(需保留人事档案)。但如果是录入错误的测试数据,应提供清理机制。
|
||||
|
||||
**优化方案**:
|
||||
- 保持正式员工不可删除,只能离职处理(合规要求)
|
||||
- 增加「作废」功能:仅限录入错误且无关联业务数据(无合同、无工资记录)的员工可作废
|
||||
- 作废后数据保留但不在花名册显示,管理员可在设置中查看作废记录
|
||||
- 增加「测试数据清理」功能:管理员可一键清除所有测试员工
|
||||
|
||||
**优先级**:P2 中
|
||||
### 问题15:员工福利新增方案后员工汇总不显示,月度合计为0
|
||||
**模块**:员工福利
|
||||
**优先级**:P1
|
||||
**现象**:新增福利方案并添加员工后,员工汇总中没有显示该福利,月度合计仍为 0。
|
||||
**可能原因**:福利汇总统计逻辑未包含新增方案的金额,或汇总查询条件未关联到新增方案。
|
||||
**优化方案**:检查福利汇总统计的查询逻辑,确保所有有效福利方案均纳入月度合计计算。
|
||||
|
||||
---
|
||||
|
||||
## 十、离职审批状态无法更改
|
||||
## 七、规章制度管理模块
|
||||
|
||||
### 10.1 待审批状态无法更改或找不到更改入口(反馈 #10)
|
||||
|
||||
**用户反馈**:选择待审批后,再点进去状态无法更改,没找到更改入口。
|
||||
|
||||
**现状分析**:`Termination.tsx` 中离职流程选择「待审批」后,可能缺少审批操作入口或状态流转不完整。
|
||||
|
||||
**代码验证**:❌ 不存在(已实现审批功能)。`Termination.tsx:967-993` 在详情视图中,当 `draftDetail.status === 'PENDING_APPROVAL'` 时,显示审批意见输入框和「审批通过」「驳回」按钮,分别调用 `approveMutation` 和 `rejectMutation`。列表视图中也有快捷审批图标按钮(828-846行)。API 层 `terminationApi.approve/reject` 完整。**用户可能是没找到详情入口**——需点击列表中的审批图标或详情按钮进入详情视图才能操作。
|
||||
|
||||
**合理性**:⚠️ 部分合理。功能已存在,但入口可能不够明显,用户没找到操作位置。建议优化 UI 引导。
|
||||
|
||||
**优化方案**:
|
||||
- 离职详情页增加「审批」按钮(通过/驳回/退回修改)
|
||||
- 待审批状态列表增加批量审批功能
|
||||
- 审批操作记录审批人、审批时间、审批意见
|
||||
- 增加审批通知提醒
|
||||
|
||||
**优先级**:P0 紧急
|
||||
### 问题8:新建规章制度无法进行文本或文档导入
|
||||
**模块**:规章制度
|
||||
**优先级**:P1
|
||||
**现象**:新建规章制度时,只能手动输入正文内容,无法导入 Word 文档或富文本。
|
||||
**优化方案**:
|
||||
- 规章制度编辑器增加"导入 Word 文档"功能(复用文本模板模块的 mammoth 导入方案)
|
||||
- 支持 .docx 格式文档导入并自动转为 HTML
|
||||
|
||||
---
|
||||
|
||||
## 十一、花名册职责边界
|
||||
## 八、文本模板模块
|
||||
|
||||
### 11.1 花名册应只读,修改到相应模块操作(反馈 #11)
|
||||
|
||||
**用户反馈**:花名册应该只能查看,修改应到相应模块,否则太乱。
|
||||
|
||||
**现状分析**:当前花名册 `Roster.tsx` 集成了大量操作:添加员工、薪资调整、部门变更、离职、重新入职等。`EmployeeProfile` 中还能直接编辑基本信息、合同、薪酬等。
|
||||
|
||||
**代码验证**:⚠️ 部分存在。`Roster.tsx` 列表支持添加员工、批量导入、导出,点击进入 `EmployeeProfile` 后可编辑基本信息、合同、薪酬等。操作确实集中在花名册中,但各编辑区域已有模块化分区(BasicInfo/ContractInfo/SalaryInfo 等)。
|
||||
|
||||
**合理性**:⚠️ 部分合理。花名册作为统一查看入口是合理的,但编辑入口确实可以更清晰。
|
||||
|
||||
**优化方案**:
|
||||
- 花名册列表保持只读查看,点击进入员工详情
|
||||
- 员工详情页保留编辑功能,但按模块分区并标注来源模块(如「基本信息 → 员工档案模块」「合同信息 → 合同管理模块」)
|
||||
- 每个编辑区域增加「前往该模块」链接,方便用户到对应模块操作
|
||||
- 花名册列表的操作按钮(薪资调整、离职等)保留,但增加模块跳转提示
|
||||
- 不建议完全移除编辑功能,因为会降低操作效率
|
||||
|
||||
**优先级**:P3 规划(涉及大量 UI 重构)
|
||||
### 问题9:下载的 Word 文档毫无格式
|
||||
**模块**:文本模板
|
||||
**优先级**:P1
|
||||
**现象**:文本模板下载的 Word 文档没有格式,段落、标题、间距等全部丢失,需要手动调整。
|
||||
**可能原因**:当前下载方式可能是纯文本写入 .doc 文件,未使用 Word HTML 格式或样式定义。
|
||||
**优化方案**:
|
||||
- 下载的 Word 文档使用完整的 Word HTML 格式(含 style 定义)
|
||||
- 保留标题、段落间距、字体大小、对齐方式等格式
|
||||
- 参考离职证明下载的 Word HTML 方案
|
||||
|
||||
---
|
||||
|
||||
## 十二、招聘模块缺失
|
||||
|
||||
### 12.1 系统没有招聘模块(反馈 #12)
|
||||
|
||||
**用户反馈**:HR 最重要的招聘环节为什么没有?
|
||||
|
||||
**现状分析**:系统目前覆盖入职→在职→离职全生命周期,但缺少招聘前端的简历管理、面试安排、Offer 管理等环节。
|
||||
|
||||
**代码验证**:✅ 确认存在。搜索全部前端代码无 `recruit`、`resume`、`interview`、`招聘`、`简历`、`面试` 相关页面或路由。系统无招聘模块。
|
||||
|
||||
**合理性**:✅ 合理。招聘是 HR 核心功能之一。
|
||||
|
||||
**优化方案**:
|
||||
- 新建招聘模块,包含:
|
||||
- **简历管理**:简历导入/录入、简历筛选、状态流转(待筛选→初试→复试→Offer→入职/淘汰)
|
||||
- **面试管理**:面试安排、面试评价、面试日历
|
||||
- **Offer 管理**:Offer 模板、Offer 发放、接受/拒绝跟踪
|
||||
- **招聘渠道**:渠道管理、来源统计
|
||||
- **人才库**:未录用候选人归档,未来岗位匹配
|
||||
- 与花名册打通:入职时自动从简历库拉取候选人信息
|
||||
- 招聘数据看板:渠道转化率、招聘周期、录用率等
|
||||
|
||||
**优先级**:P3 规划(大型新功能,建议单独规划版本)
|
||||
### 问题10:无固定期限劳动合同模板下载后不含员工信息
|
||||
**模块**:文本模板
|
||||
**优先级**:P1
|
||||
**现象**:使用"无固定期限劳动合同"模板,已填写了变量占位符,但下载的 Word 文档中变量未被替换为实际员工信息。
|
||||
**可能原因**:下载时未对模板内容进行变量替换处理,直接输出了原始模板。
|
||||
**优化方案**:
|
||||
- 下载 Word 文档时自动替换模板中的变量占位符(如 {{name}}、{{idCardNumber}} 等)
|
||||
- 如果是从员工详情页发起下载,自动填充该员工的信息
|
||||
- 如果是预览下载,提示用户填写变量或选择关联员工
|
||||
|
||||
---
|
||||
|
||||
## 十三、角色权限分工
|
||||
## 九、电子签署模块
|
||||
|
||||
### 13.1 专员和主管的权限分工(反馈 #13)
|
||||
|
||||
**用户反馈**:工作角色分类,是否考虑有专员和主管的权限分工。
|
||||
|
||||
**现状分析**:当前系统角色为 `SUPER_ADMIN`、`ADMIN`、`HR`、`VIEWER` 四级,`HR` 角色拥有全部 HR 操作权限,无专员/主管区分。
|
||||
|
||||
**代码验证**:✅ 确认存在。`authStore.ts` 中 User 角色为 `SUPER_ADMIN | ADMIN | HR | VIEWER` 四级,无专员/主管区分。所有 HR 角色拥有相同权限,无法按模块或操作类型细分。
|
||||
|
||||
**合理性**:✅ 合理。中大型企业需要更细粒度的权限控制。
|
||||
|
||||
**优化方案**:
|
||||
- 角色体系扩展:
|
||||
- `HR_SUPERVISOR`(HR 主管):全部查看 + 审批权限 + 配置权限
|
||||
- `HR_SPECIALIST`(HR 专员):数据录入 + 日常操作,无审批和配置权限
|
||||
- 权限粒度:
|
||||
- 查看权限:全部模块可查看
|
||||
- 操作权限:专员可录入/修改,主管可审批/删除/导出
|
||||
- 配置权限:仅主管/管理员可修改系统配置(社保比例、薪资结构等)
|
||||
- 审批流:离职、薪资调整等关键操作需主管审批
|
||||
- 可按模块设置权限(如考勤专员只能操作考勤模块)
|
||||
|
||||
**优先级**:P2 中
|
||||
### 问题11:发起签署时选择员工没有选项
|
||||
**模块**:电子签署
|
||||
**优先级**:P0
|
||||
**现象**:电子签署中发起签署时,选择员工的下拉框为空,没有任何选项。
|
||||
**可能原因**:员工列表查询接口未调用或返回数据为空,或下拉框数据绑定逻辑有误。
|
||||
**优化方案**:检查电子签署发起页面中员工列表的数据获取逻辑,确保正确加载在职员工列表。
|
||||
|
||||
---
|
||||
|
||||
## 优先级汇总
|
||||
## 十、绩效考核模块
|
||||
|
||||
| 优先级 | 编号 | 问题 | 状态 |
|
||||
|--------|------|------|------|
|
||||
| 优先级 | 编号 | 问题 | 验证状态 | 修复状态 |
|
||||
|--------|------|------|----------|----------|
|
||||
| ~~P0~~ | 2.1 | 导入错误提示乱码 | ⚠️ 部分存在(已有错误展示和导出,但信息可能含英文字段名) | 降级 P1 |
|
||||
| ~~P0~~ | 4.1 | 社保基数不同步 | ❌ 不存在(代码已正确实现优先级 socialInsBase > salary) | 降级 P2(增加 UI 提示) |
|
||||
| P0 紧急 | 8.1 | 员工办理关键信息未设必填 | ✅ 确认存在 | 待修复 |
|
||||
| ~~P0~~ | 10.1 | 离职审批状态无法更改 | ❌ 不存在(已实现完整审批流程) | 无需修复(优化 UI 引导) |
|
||||
| P1 高 | 1.1 | 发薪数据每月需重新导入 | ✅ 确认存在 | 待优化 |
|
||||
| P1 高 | 3.1 | 工资流水导出格式适配银行 | ✅ 确认存在 | 待开发 |
|
||||
| P1 高 | 5.1 | 商险管理添加员工入口缺失 | ✅ 确认存在 | 待修复 |
|
||||
| ~~P1~~ | 6.1 | 员工福利月度汇总无数据 | ❌ 系统无福利模块 | 需澄清需求 |
|
||||
| P2 中 | 7.1 | 证据链验证不清晰 | ⚠️ 部分存在(有验证但不显示具体原因) | 待优化 |
|
||||
| P2 中 | 9.1 | 员工删除限制 | ✅ 确认存在(无删除入口) | 待优化 |
|
||||
| P2 中 | 13.1 | 角色权限分工 | ✅ 确认存在 | 待规划 |
|
||||
| P3 规划 | 11.1 | 花名册职责边界重构 | ⚠️ 部分存在 | 待规划 |
|
||||
| P3 规划 | 12.1 | 招聘模块 | ✅ 确认不存在 | 待规划 |
|
||||
### 问题12:新增绩效记录没有编辑考评人的选项
|
||||
**模块**:绩效考核
|
||||
**优先级**:P2
|
||||
**现象**:绩效考核新增记录时,表单中没有考评人(考核人)的输入字段。
|
||||
**优化方案**:在绩效记录新增表单中增加"考评人"输入框(已有 reviewer 字段,需确认前端是否显示)。
|
||||
|
||||
---
|
||||
|
||||
## 备注
|
||||
## 十一、违纪记录模块
|
||||
|
||||
## 代码验证结论
|
||||
### 问题13:违纪记录缺少处罚执行细节
|
||||
**模块**:违纪记录
|
||||
**优先级**:P2
|
||||
**现象**:违纪记录只有删除功能,缺少处罚执行的具体管理。用户希望支持:
|
||||
1. 处罚类型为扣款/降职时,区分一次性处罚还是持续处罚(如罚一个月后恢复)
|
||||
2. 停薪留职时,可设定留职时间段或灵活安排结束时间
|
||||
**优化方案**:
|
||||
- 违纪记录新增"处罚执行"字段:处罚方式(一次性/持续性)、处罚开始日期、处罚结束日期、处罚金额/降职后岗位
|
||||
- 停薪留职支持设定预计结束日期,到期后提醒确认是否恢复
|
||||
- 违纪记录列表增加处罚状态列(执行中/已结束/已恢复)
|
||||
|
||||
### 已确认存在的问题(需修复)
|
||||
- **#8 员工办理必填项缺失**:`WorkProcess.tsx` 无任何表单必填校验,只填姓名即可提交
|
||||
- **#5 商险添加员工入口缺失**:`CommercialInsuranceTab.tsx` 无添加参保员工按钮和 API
|
||||
- **#1 发薪数据重复导入**:无「复制上月批次」功能
|
||||
- **#3 工资流水无银行格式导出**:`PayslipTab.tsx` 无导出功能
|
||||
- **#9 员工无法删除**:花名册无删除入口
|
||||
- **#12 无招聘模块**:系统中完全不存在
|
||||
- **#13 角色权限无专员/主管分工**:仅四级角色
|
||||
---
|
||||
|
||||
### 不存在或已实现的问题(无需修复)
|
||||
- **#4 社保基数不同步**:`payroll.service.ts:166` 已正确实现 `socialInsBase || salary` 优先级,问题可能是用户未设置 `socialInsBase`
|
||||
- **#10 离职审批无法更改**:`Termination.tsx:967-993` 已实现完整审批操作(通过/驳回),用户可能未找到详情入口
|
||||
- **#6 员工福利汇总无数据**:系统中无福利模块,需澄清用户具体指什么
|
||||
## 十二、合规模块
|
||||
|
||||
### 部分存在的问题(可优化)
|
||||
- **#2 导入错误乱码**:已有错误展示和导出功能,但错误信息可能含英文字段名
|
||||
- **#7 证据链验证**:有验证功能但不显示具体异常原因
|
||||
- **#11 花名册职责边界**:编辑功能已模块化分区但确实集中在花名册中
|
||||
### 问题16:证据链验证完整性全部失败
|
||||
**模块**:合规 → 证据链
|
||||
**优先级**:P0
|
||||
**现象**:合规模块中证据链验证完整性,所有验证项全部失败。
|
||||
**可能原因**:
|
||||
1. 证据链哈希计算逻辑与存储时的哈希不一致(如排序规则、拼接顺序变化)
|
||||
2. 历史数据在升级过程中哈希规则变更,导致旧数据全部校验失败
|
||||
3. orgId 或其他参与哈希计算的参数发生变化
|
||||
**优化方案**:
|
||||
- 检查 verifyAllEvidence 中的哈希计算逻辑,对比存储时的哈希生成逻辑
|
||||
- 如果是哈希规则变更导致,提供"重新生成哈希"功能(仅管理员可用)
|
||||
- 排查是否有数据迁移或 orgId 变更导致的不一致
|
||||
|
||||
### 备注
|
||||
- #4 和 #10 经代码验证不构成 bug,降级处理
|
||||
- #6 需与用户澄清具体需求
|
||||
- #12 招聘模块为大型新功能,建议单独规划版本迭代
|
||||
---
|
||||
|
||||
## 汇总
|
||||
|
||||
| 优先级 | 数量 | 问题编号 |
|
||||
|--------|------|----------|
|
||||
| P0 | 5 | 4, 6, 7, 11, 16 |
|
||||
| P1 | 7 | 1, 2, 3, 5, 8, 9, 10, 15 |
|
||||
| P2 | 2 | 12, 13 |
|
||||
| 待确认 | 1 | 14 |
|
||||
| **合计** | **16** | |
|
||||
|
||||
@@ -585,6 +585,9 @@ export const socialInsuranceApi = {
|
||||
/** 公积金活跃申报 */
|
||||
housingActiveDeclaration: (month: string) =>
|
||||
get('/social/housing/active-declaration', { params: { month } }).then(unwrap<any>()),
|
||||
/** 员工参保信息列表 */
|
||||
employeeEnrollment: (keyword?: string) =>
|
||||
get('/social/employee-enrollment', { params: keyword ? { keyword } : {} }).then(unwrap<any[]>()),
|
||||
}
|
||||
|
||||
// ========== 商业保险 ==========
|
||||
@@ -1053,6 +1056,9 @@ export const portalApi = {
|
||||
/** 撤回离职申请 */
|
||||
resignationWithdraw: (id: string) =>
|
||||
portalPost(`/resignation/${id}/withdraw`).then(unwrap<any>()),
|
||||
/** 下载离职证明 */
|
||||
downloadCertificate: (id: string) =>
|
||||
portalGet(`/resignation/${id}/certificate`, { responseType: 'blob' }) as any,
|
||||
/** 我的休假申请列表 */
|
||||
myLeaves: () =>
|
||||
portalGet('/leaves').then(unwrap<any[]>()),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { PenTool, Plus, X, RefreshCw, ExternalLink, FileText, AlertCircle } from 'lucide-react'
|
||||
import { esignApi, rosterApi } from '../lib/api-services'
|
||||
import { esignApi, employeeApi } from '../lib/api-services'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
@@ -45,10 +45,10 @@ export default function ESign() {
|
||||
},
|
||||
})
|
||||
|
||||
const { data: rosterData } = useQuery<any>({
|
||||
queryKey: ['roster-for-esign'],
|
||||
const { data: rosterData = [] } = useQuery<any[]>({
|
||||
queryKey: ['employees-for-esign'],
|
||||
queryFn: async () => {
|
||||
return await rosterApi.list({ search: '', page: 1, pageSize: 200 } as any) as any
|
||||
return await employeeApi.allLite({ status: 'ACTIVE' })
|
||||
},
|
||||
enabled: showCreate,
|
||||
})
|
||||
@@ -224,7 +224,7 @@ export default function ESign() {
|
||||
onChange={(e) => setFormData({ ...formData, employeeId: e.target.value })}
|
||||
>
|
||||
<option value="">请选择员工</option>
|
||||
{rosterData?.items?.filter((e: any) => e.status === 'ACTIVE').map((emp: any) => (
|
||||
{rosterData.map((emp: any) => (
|
||||
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -230,7 +230,7 @@ export default function EmployeeBenefits() {
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium">参保人员({enrollments.length}人)</h3>
|
||||
<Button size="sm" variant="secondary" onClick={() => { setEnrollEmployeeIds([]); setShowEnrollModal(true) }}>
|
||||
<Users className="w-3.5 h-3.5 mr-1" />批量参保
|
||||
<Users className="w-3.5 h-3.5 mr-1" />为员工添加福利
|
||||
</Button>
|
||||
</div>
|
||||
{enrollLoading ? (
|
||||
@@ -393,7 +393,7 @@ export default function EmployeeBenefits() {
|
||||
|
||||
{/* 批量参保 Modal */}
|
||||
{showEnrollModal && (
|
||||
<Modal open={true} onClose={() => setShowEnrollModal(false)} title="批量参保" size="lg">
|
||||
<Modal open={true} onClose={() => setShowEnrollModal(false)} title="为员工添加福利" size="lg">
|
||||
<div className="space-y-3">
|
||||
<InlineAlert type="info">
|
||||
选择需要参保的员工,设置生效月份后点击「确认参保」。
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function Evidence() {
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{verifyResult?.invalid === 0
|
||||
? `全部 ${verifyResult?.total || 0} 条证据链验证通过,数据完整无篡改`
|
||||
? `全部 ${verifyResult?.total || 0} 条证据链验证通过,数据完整无篡改${verifyResult?.repaired ? `(自动修复 ${verifyResult.repaired} 条历史数据)` : ''}`
|
||||
: `${verifyResult?.valid || 0} 条通过,${verifyResult?.invalid || 0} 条异常,请检查`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef } from 'react'
|
||||
import { usePageSize } from '../hooks/usePageSize'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { FileText, Plus, ChevronRight, CheckCircle, Clock, X, Bell } from 'lucide-react'
|
||||
import { FileText, Plus, ChevronRight, CheckCircle, Clock, X, Bell, Upload } from 'lucide-react'
|
||||
import mammoth from 'mammoth'
|
||||
import { policiesApi } from '../lib/api-services'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
@@ -201,6 +202,7 @@ function CreatePolicyModal({ onClose, onSuccess }: { onClose: () => void; onSucc
|
||||
const [title, setTitle] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [type, setType] = useState('RULES')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => policiesApi.create({ title, content, type }),
|
||||
@@ -230,7 +232,32 @@ function CreatePolicyModal({ onClose, onSuccess }: { onClose: () => void; onSucc
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600">制度内容</label>
|
||||
<textarea value={content} onChange={e => setContent(e.target.value)} rows={8} className="w-full mt-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" placeholder="输入制度正文..." />
|
||||
<div className="flex items-center gap-2 mt-1 mb-1">
|
||||
<Button size="sm" variant="secondary" className="!h-7" onClick={() => fileInputRef.current?.click()}>
|
||||
<Upload className="w-3.5 h-3.5 mr-1" />导入 Word 文档
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".docx"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const result = await mammoth.convertToHtml({ arrayBuffer })
|
||||
setContent(result.value)
|
||||
toast.success('文档导入成功')
|
||||
} catch {
|
||||
toast.error('文档解析失败,请确保为 .docx 格式')
|
||||
}
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-gray-400">支持 .docx 格式,导入后转为 HTML</span>
|
||||
</div>
|
||||
<textarea value={content} onChange={e => setContent(e.target.value)} rows={8} className="w-full mt-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" placeholder="输入制度正文或导入 Word 文档..." />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={onClose}>取消</Button>
|
||||
|
||||
@@ -111,9 +111,16 @@ export default function Roster() {
|
||||
})
|
||||
const pagination = rosterData?.pagination || { page, pageSize, total: 0, totalPages: 0 }
|
||||
|
||||
// 从 URL query 参数自动定位员工(从待办跳转时)
|
||||
// 从 URL query 参数自动定位员工(从待办跳转或绩效/培训列表跳转时)
|
||||
const employeeParam = searchParams.get('employee')
|
||||
const employeeIdParam = searchParams.get('employeeId')
|
||||
useEffect(() => {
|
||||
// 优先按 employeeId 直接定位
|
||||
if (employeeIdParam && !selectedId) {
|
||||
setSelectedId(employeeIdParam)
|
||||
setSearchParams({}, { replace: true })
|
||||
return
|
||||
}
|
||||
if (!employeeParam) return
|
||||
setSearch(employeeParam)
|
||||
// 只有当 debouncedSearch 与参数一致时,搜索结果才真正匹配
|
||||
@@ -122,7 +129,7 @@ export default function Roster() {
|
||||
// 清除 URL 参数,避免后续搜索时重复触发
|
||||
setSearchParams({}, { replace: true })
|
||||
}
|
||||
}, [employeeParam, debouncedSearch, isLoading, employees, selectedId, setSearchParams])
|
||||
}, [employeeParam, employeeIdParam, debouncedSearch, isLoading, employees, selectedId, setSearchParams])
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (data: any) => employeeApi.create(data),
|
||||
|
||||
@@ -11,6 +11,7 @@ import Button from '../components/ui/Button'
|
||||
import { Input, Label } from '../components/ui/Input'
|
||||
import { MonthlyRow, MonthlyHousingRow } from './social-insurance/MonthlyRows'
|
||||
import SpecialDeductionTab from './social-insurance/SpecialDeductionTab'
|
||||
import EmployeeEnrollmentTab from './social-insurance/EmployeeEnrollmentTab'
|
||||
|
||||
// 金额格式化:保留两位小数 + 千分位
|
||||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
@@ -18,7 +19,7 @@ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDig
|
||||
export default function SocialInsurance() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction'>('monthly')
|
||||
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction' | 'enrollment'>('monthly')
|
||||
const [city, setCity] = useState<string>('北京')
|
||||
const [showAddCity, setShowAddCity] = useState(false)
|
||||
const [newCityName, setNewCityName] = useState('')
|
||||
@@ -374,7 +375,7 @@ export default function SocialInsurance() {
|
||||
|
||||
{/* Tab 切换 + 城市选择 */}
|
||||
<div className="flex items-center gap-4 border-b">
|
||||
{(['monthly', 'social', 'housing', 'deduction'] as const).map((t) => (
|
||||
{(['monthly', 'social', 'housing', 'enrollment', 'deduction'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
@@ -382,7 +383,7 @@ export default function SocialInsurance() {
|
||||
}`}
|
||||
onClick={() => { setTab(t); setShowVersions(false); setShowNewVersion(false); setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null); setMonthlyProcessed(false); setProcessStatus(null) }}
|
||||
>
|
||||
{t === 'monthly' ? '月度办理' : t === 'social' ? '社保' : t === 'housing' ? '公积金' : '专项附加扣除'}
|
||||
{t === 'monthly' ? '月度办理' : t === 'social' ? '社保' : t === 'housing' ? '公积金' : t === 'enrollment' ? '员工参保' : '专项附加扣除'}
|
||||
</button>
|
||||
))}
|
||||
{(tab === 'social' || tab === 'housing') && (
|
||||
@@ -1150,6 +1151,11 @@ export default function SocialInsurance() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== 员工参保信息 Tab ========== */}
|
||||
{tab === 'enrollment' && (
|
||||
<EmployeeEnrollmentTab />
|
||||
)}
|
||||
|
||||
{/* ========== 专项附加扣除 Tab ========== */}
|
||||
{tab === 'deduction' && (
|
||||
<SpecialDeductionTab month={deductionMonth} setMonth={setDeductionMonth} />
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { usePageSize } from '../hooks/usePageSize'
|
||||
import { Search, Plus, Edit2, Trash2, AlertTriangle, Clock, Baby, HeartPulse, Activity, X } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Search, Plus, Edit2, Trash2, AlertTriangle, Clock, Baby, HeartPulse, Activity, X, Eye } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { specialStatusApi, employeeApi } from '../lib/api-services'
|
||||
import { Input, Select, Label } from '../components/ui/Input'
|
||||
@@ -351,6 +352,9 @@ export default function SpecialStatus() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Link to={`/roster?employeeId=${item.employee.id}`} className="p-1 rounded text-gray-400 hover:text-primary hover:bg-gray-100" title="查看员工详情">
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
<button onClick={() => handleOpenEdit(item)} className="p-1 rounded text-gray-400 hover:text-primary hover:bg-gray-100" title="编辑">
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -362,7 +366,7 @@ export default function SpecialStatus() {
|
||||
|
||||
{/* 员工信息 */}
|
||||
<div className="mb-3">
|
||||
<span className="font-medium text-gray-900">{item.employee.name}</span>
|
||||
<Link to={`/roster?employeeId=${item.employee.id}`} className="font-medium text-gray-900 hover:text-primary hover:underline">{item.employee.name}</Link>
|
||||
<span className="text-sm text-gray-500 ml-2">{item.employee.department}</span>
|
||||
{item.employee.status === 'RESIGNED' && (
|
||||
<span className="ml-2 text-xs text-gray-400">已离职</span>
|
||||
|
||||
@@ -142,25 +142,21 @@ function SystemTemplates() {
|
||||
toast.success('已复制到剪贴板')
|
||||
}
|
||||
|
||||
const handleDownloadWord = async () => {
|
||||
const handleDownloadWord = () => {
|
||||
if (!selected) return
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
|
||||
const res = await fetch(`${baseURL}/templates/${selected.id}/download`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${selected.name}.doc`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('已下载 Word 文档')
|
||||
} catch {
|
||||
toast.error('下载失败')
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
|
||||
const query = new URLSearchParams()
|
||||
query.set('token', token || '')
|
||||
for (const [k, v] of Object.entries(variables)) {
|
||||
if (v) query.append(k, v)
|
||||
}
|
||||
const a = document.createElement('a')
|
||||
a.href = `${baseURL}/templates/${selected.id}/download?${query.toString()}`
|
||||
a.style.display = 'none'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
const handleCopyAsNew = () => {
|
||||
@@ -373,25 +369,21 @@ function EnterpriseTemplates() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadWord = async () => {
|
||||
const handleDownloadWord = () => {
|
||||
if (!selected) return
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
|
||||
const res = await fetch(`${baseURL}/enterprise-templates/${selected.id}/download`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${selected.name}.doc`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('已下载')
|
||||
} catch {
|
||||
toast.error('下载失败')
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
|
||||
const query = new URLSearchParams()
|
||||
query.set('token', token || '')
|
||||
for (const [k, v] of Object.entries(variables)) {
|
||||
if (v) query.append(k, v)
|
||||
}
|
||||
const a = document.createElement('a')
|
||||
a.href = `${baseURL}/enterprise-templates/${selected.id}/download?${query.toString()}`
|
||||
a.style.display = 'none'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
const handleEdit = (item: any) => {
|
||||
|
||||
@@ -482,6 +482,9 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
||||
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['batches'] })
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.response?.data?.error?.message || '保存失败,请重试')
|
||||
},
|
||||
})
|
||||
|
||||
const removeEmployeeMutation = useMutation({
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { UserX, Clock, FileText, Camera, X, Image as ImageIcon } from 'lucide-react'
|
||||
import { UserX, Clock, FileText, Camera, X, Image as ImageIcon, Download } from 'lucide-react'
|
||||
import { portalApi } from '../../lib/api-services'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
@@ -205,6 +205,7 @@ export default function ResignationApply() {
|
||||
{records.map((r: any) => {
|
||||
const statusCfg = STATUS_MAP[r.status] || STATUS_MAP.DRAFT
|
||||
const canWithdraw = r.status === 'DRAFT' || r.status === 'PENDING_APPROVAL'
|
||||
const canDownload = r.status === 'COMPLETED'
|
||||
return (
|
||||
<div key={r.id} className="p-3 rounded-lg border border-gray-100">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
@@ -223,6 +224,29 @@ export default function ResignationApply() {
|
||||
<div className="text-xs text-gray-500 mt-1">{r.remark}</div>
|
||||
)}
|
||||
</div>
|
||||
{canDownload && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-50">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const blob = await portalApi.downloadCertificate(r.id)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `离职证明.doc`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
toast.error('下载失败')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-1" />下载离职证明
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{canWithdraw && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-50">
|
||||
<Button
|
||||
|
||||
@@ -106,19 +106,20 @@ export default function DisciplinaryRecords() {
|
||||
<th className="pb-2 pr-4 font-medium">描述</th>
|
||||
<th className="pb-2 pr-4 font-medium">严重程度</th>
|
||||
<th className="pb-2 pr-4 font-medium">处理</th>
|
||||
<th className="pb-2 pr-4 font-medium">执行细节</th>
|
||||
<th className="pb-2 pr-4 font-medium">签字</th>
|
||||
<th className="pb-2 pr-4 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr><td colSpan={9} className="py-8 text-center text-gray-400">加载中...</td></tr>
|
||||
<tr><td colSpan={10} className="py-8 text-center text-gray-400">加载中...</td></tr>
|
||||
) : records.length === 0 ? (
|
||||
<tr><td colSpan={9} className="py-8 text-center text-gray-400">暂无违纪记录</td></tr>
|
||||
<tr><td colSpan={10} className="py-8 text-center text-gray-400">暂无违纪记录</td></tr>
|
||||
) : records.map((r: any) => (
|
||||
<tr key={r.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-2 pr-4">
|
||||
<Link to={`/roster/${r.employeeId}`} className="text-primary hover:underline">{r.employee?.name}</Link>
|
||||
<Link to={`/roster?employeeId=${r.employeeId}`} className="text-primary hover:underline">{r.employee?.name}</Link>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-gray-600">{r.employee?.department || '-'}</td>
|
||||
<td className="py-2 pr-4">{fmtDate(r.violationDate)}</td>
|
||||
@@ -130,6 +131,7 @@ export default function DisciplinaryRecords() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-gray-600">{ACTION_LABELS[r.action] || r.action}</td>
|
||||
<td className="py-2 pr-4 max-w-xs truncate text-gray-500 text-xs" title={r.actionDetail}>{r.actionDetail || '-'}</td>
|
||||
<td className="py-2 pr-4">
|
||||
{r.employeeAck ? (
|
||||
<span className="text-xs text-green-600">已签字</span>
|
||||
@@ -284,8 +286,8 @@ function DisciplinaryForm({ employees, record, onSubmit, onClose }: {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>处理详情</Label>
|
||||
<Input value={form.actionDetail} onChange={(e) => setForm({ ...form, actionDetail: e.target.value })} placeholder="处理详情(选填)" />
|
||||
<Label>处罚执行细节</Label>
|
||||
<textarea value={form.actionDetail} onChange={(e) => setForm({ ...form, actionDetail: e.target.value })} rows={3} className="w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" placeholder="详细说明处罚执行情况,如扣款金额、书面警告文号、降职后岗位等" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>见证人</Label>
|
||||
|
||||
@@ -122,7 +122,7 @@ export default function PerformanceRecords() {
|
||||
) : records.map((r: any) => (
|
||||
<tr key={r.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-2 pr-4">
|
||||
<Link to={`/roster/${r.employeeId}`} className="text-primary hover:underline">{r.employee?.name}</Link>
|
||||
<Link to={`/roster?employeeId=${r.employeeId}`} className="text-primary hover:underline">{r.employee?.name}</Link>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-gray-600">{r.employee?.department || '-'}</td>
|
||||
<td className="py-2 pr-4">{r.period}</td>
|
||||
@@ -198,10 +198,19 @@ function PerformanceForm({ employees, templates, record, onSubmit, onClose }: {
|
||||
onSubmit: (data: any) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const detectPeriodType = (period: string, fallback?: string): string => {
|
||||
if (/^\d{4}-Q[1-4]$/.test(period)) return 'QUARTERLY'
|
||||
if (/^\d{4}$/.test(period)) return 'YEARLY'
|
||||
if (/^\d{4}-\d{2}$/.test(period)) return 'MONTHLY'
|
||||
return fallback || 'MONTHLY'
|
||||
}
|
||||
|
||||
const initialPeriodType = record ? detectPeriodType(record.period, record.periodType) : 'MONTHLY'
|
||||
|
||||
const [form, setForm] = useState({
|
||||
employeeId: record?.employeeId || '',
|
||||
period: record?.period || new Date().toISOString().slice(0, 7),
|
||||
periodType: record?.periodType || 'MONTHLY',
|
||||
periodType: initialPeriodType,
|
||||
score: record?.score || 80,
|
||||
grade: record?.grade || 'B',
|
||||
result: record?.result || 'QUALIFIED',
|
||||
@@ -260,16 +269,22 @@ function PerformanceForm({ employees, templates, 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>
|
||||
)}
|
||||
<div>
|
||||
<Label>考核类型</Label>
|
||||
<Select value={form.periodType} onChange={(e) => {
|
||||
const newType = e.target.value
|
||||
let newPeriod = form.period
|
||||
const year = form.period.slice(0, 4) || new Date().toISOString().slice(0, 4)
|
||||
if (newType === 'MONTHLY') newPeriod = year + '-' + (new Date().getMonth() + 1).toString().padStart(2, '0')
|
||||
else if (newType === 'QUARTERLY') newPeriod = year + '-Q1'
|
||||
else if (newType === 'YEARLY') newPeriod = year
|
||||
setForm({ ...form, periodType: newType, period: newPeriod })
|
||||
}}>
|
||||
<option value="MONTHLY">月度考核</option>
|
||||
<option value="QUARTERLY">季度考核</option>
|
||||
<option value="YEARLY">年度考核</option>
|
||||
</Select>
|
||||
</div>
|
||||
{!record && (
|
||||
<div>
|
||||
<Label>员工</Label>
|
||||
@@ -283,7 +298,12 @@ function PerformanceForm({ employees, templates, record, onSubmit, onClose }: {
|
||||
)}
|
||||
<div>
|
||||
<Label>考核周期</Label>
|
||||
<Input type={form.periodType === 'YEARLY' ? 'number' : 'month'} value={form.period} onChange={(e) => setForm({ ...form, period: e.target.value })} placeholder={form.periodType === 'YEARLY' ? '如 2026' : undefined} />
|
||||
<Input
|
||||
type={form.periodType === 'YEARLY' ? 'number' : form.periodType === 'QUARTERLY' ? 'text' : 'month'}
|
||||
value={form.period}
|
||||
onChange={(e) => setForm({ ...form, period: e.target.value })}
|
||||
placeholder={form.periodType === 'YEARLY' ? '如 2026' : form.periodType === 'QUARTERLY' ? '如 2026-Q1' : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>绩效模板(选填)</Label>
|
||||
@@ -351,8 +371,8 @@ function PerformanceForm({ employees, templates, record, onSubmit, onClose }: {
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>考评人</Label>
|
||||
<Input value={form.reviewer} onChange={(e) => setForm({ ...form, reviewer: e.target.value })} placeholder="考评人姓名" />
|
||||
<Label>考评人 *</Label>
|
||||
<Input value={form.reviewer} onChange={(e) => setForm({ ...form, reviewer: e.target.value })} placeholder="请输入考评人姓名" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>评语</Label>
|
||||
@@ -376,7 +396,7 @@ function PerformanceForm({ employees, templates, record, onSubmit, onClose }: {
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" size="sm" onClick={onClose}>取消</Button>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!form.employeeId || !form.period}>保存</Button>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!form.employeeId || !form.period || !form.reviewer}>保存</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -154,7 +154,7 @@ export default function TrainingRecords() {
|
||||
) : records.map((r: any) => (
|
||||
<tr key={r.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-2 pr-4">
|
||||
<Link to={`/roster/${r.employeeId}`} className="text-primary hover:underline">{r.employee?.name}</Link>
|
||||
<Link to={`/roster?employeeId=${r.employeeId}`} className="text-primary hover:underline">{r.employee?.name}</Link>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-gray-600">{r.employee?.department || '-'}</td>
|
||||
<td className="py-2 pr-4">{fmtDate(r.trainingDate)}</td>
|
||||
|
||||
@@ -2,12 +2,13 @@ import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useConfirm } from '../../hooks/useConfirm'
|
||||
import { Plus, Settings as SettingsIcon, X, Shield } from 'lucide-react'
|
||||
import { commercialInsuranceApi } from '../../lib/api-services'
|
||||
import { Plus, Settings as SettingsIcon, X, Shield, UserPlus } from 'lucide-react'
|
||||
import { commercialInsuranceApi, employeeApi } from '../../lib/api-services'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
import { Input, Label } from '../../components/ui/Input'
|
||||
import { InlineAlert } from '../../components/ui/InlineAlert'
|
||||
import Modal from '../../components/ui/Modal'
|
||||
|
||||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
@@ -36,6 +37,9 @@ export default function CommercialInsuranceTab() {
|
||||
const [editingPlan, setEditingPlan] = useState<any>(null)
|
||||
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null)
|
||||
const [newPlan, setNewPlan] = useState<any>({ ...DEFAULT_PLAN })
|
||||
const [showEnrollModal, setShowEnrollModal] = useState(false)
|
||||
const [enrollEmployeeIds, setEnrollEmployeeIds] = useState<string[]>([])
|
||||
const [enrollEffectiveFrom, setEnrollEffectiveFrom] = useState(new Date().toISOString().slice(0, 10))
|
||||
|
||||
const { data: plans = [], isLoading } = useQuery<any[]>({
|
||||
queryKey: ['commercial-insurance-plans'],
|
||||
@@ -79,6 +83,27 @@ export default function CommercialInsuranceTab() {
|
||||
},
|
||||
})
|
||||
|
||||
const { data: rosterData } = useQuery<any[]>({
|
||||
queryKey: ['employees-for-commercial-insurance'],
|
||||
queryFn: async () => {
|
||||
return await employeeApi.allLite({ status: 'ACTIVE' })
|
||||
},
|
||||
enabled: showEnrollModal,
|
||||
})
|
||||
|
||||
const enrollMutation = useMutation({
|
||||
mutationFn: async (data: { employeeIds: string[]; effectiveFrom?: string }) =>
|
||||
commercialInsuranceApi.enroll(selectedPlanId!, data) as any,
|
||||
onSuccess: (res: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['commercial-insurance-enrollments'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['commercial-insurance-employee-summary'] })
|
||||
setShowEnrollModal(false)
|
||||
setEnrollEmployeeIds([])
|
||||
toast.success(`已添加 ${res.data?.enrolled || 0} 名员工`)
|
||||
},
|
||||
onError: () => toast.error('参保失败'),
|
||||
})
|
||||
|
||||
const handleEdit = (plan: any) => {
|
||||
setEditingPlan(plan)
|
||||
setNewPlan({ ...plan })
|
||||
@@ -157,7 +182,12 @@ export default function CommercialInsuranceTab() {
|
||||
|
||||
{selectedPlanId && (
|
||||
<Card>
|
||||
<h3 className="text-sm font-medium mb-3">参保人员({enrollments.length}人)</h3>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium">参保人员({enrollments.length}人)</h3>
|
||||
<Button size="sm" onClick={() => { setEnrollEmployeeIds([]); setEnrollEffectiveFrom(new Date().toISOString().slice(0, 10)); setShowEnrollModal(true) }}>
|
||||
<UserPlus className="w-4 h-4 mr-1" />为员工参保
|
||||
</Button>
|
||||
</div>
|
||||
{enrollLoading ? (
|
||||
<div className="text-center py-4 text-gray-400 text-sm">加载中...</div>
|
||||
) : enrollments.length === 0 ? (
|
||||
@@ -197,6 +227,70 @@ export default function CommercialInsuranceTab() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 批量参保 Modal */}
|
||||
{showEnrollModal && (
|
||||
<Modal open={true} onClose={() => setShowEnrollModal(false)} title="批量参保" size="lg">
|
||||
<div className="space-y-3">
|
||||
<InlineAlert type="info">
|
||||
选择需要参保的员工,设置生效日期后点击「确认参保」。
|
||||
</InlineAlert>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="shrink-0">生效日期</Label>
|
||||
<Input type="date" value={enrollEffectiveFrom} onChange={(e) => setEnrollEffectiveFrom(e.target.value)} className="!w-40" />
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto border rounded-md">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-white">
|
||||
<tr className="border-b text-xs text-gray-500">
|
||||
<th className="py-2 px-3 text-left w-8">
|
||||
<input type="checkbox" checked={enrollEmployeeIds.length === (rosterData?.length || 0) && enrollEmployeeIds.length > 0}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setEnrollEmployeeIds(rosterData?.map((emp: any) => emp.id) || [])
|
||||
} else {
|
||||
setEnrollEmployeeIds([])
|
||||
}
|
||||
}} />
|
||||
</th>
|
||||
<th className="py-2 px-3 text-left">姓名</th>
|
||||
<th className="py-2 px-3 text-left">部门</th>
|
||||
<th className="py-2 px-3 text-left">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rosterData?.map((emp: any) => (
|
||||
<tr key={emp.id} className="border-b last:border-0 hover:bg-gray-50">
|
||||
<td className="py-2 px-3">
|
||||
<input type="checkbox" checked={enrollEmployeeIds.includes(emp.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) setEnrollEmployeeIds([...enrollEmployeeIds, emp.id])
|
||||
else setEnrollEmployeeIds(enrollEmployeeIds.filter((id) => id !== emp.id))
|
||||
}} />
|
||||
</td>
|
||||
<td className="py-2 px-3 font-medium">{emp.name}</td>
|
||||
<td className="py-2 px-3 text-gray-500">{emp.department}</td>
|
||||
<td className="py-2 px-3">
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-green-50 text-safe">在职</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-400">已选 {enrollEmployeeIds.length} 人</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowEnrollModal(false)}>取消</Button>
|
||||
<Button size="sm" onClick={() => enrollMutation.mutate({ employeeIds: enrollEmployeeIds, effectiveFrom: enrollEffectiveFrom })}
|
||||
disabled={enrollEmployeeIds.length === 0 || enrollMutation.isPending}>
|
||||
{enrollMutation.isPending ? '参保中...' : '确认参保'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showAddPlan && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={() => setShowAddPlan(false)}>
|
||||
<Card className="w-full max-w-lg mx-4" >
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Search, CheckCircle, XCircle } from 'lucide-react'
|
||||
import { socialInsuranceApi } from '../../lib/api-services'
|
||||
import { Input } from '../../components/ui/Input'
|
||||
import { useDebouncedValue } from '../../hooks/useDebouncedValue'
|
||||
|
||||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
export default function EmployeeEnrollmentTab() {
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const debouncedSearch = useDebouncedValue(keyword, 300)
|
||||
|
||||
const { data: list = [], isLoading } = useQuery<any[]>({
|
||||
queryKey: ['social-employee-enrollment', debouncedSearch],
|
||||
queryFn: () => socialInsuranceApi.employeeEnrollment(debouncedSearch || undefined),
|
||||
})
|
||||
|
||||
const insuredCount = list.filter(e => e.socialInsStatus === 'INSURED').length
|
||||
const housingCount = list.filter(e => e.housingFundStatus === 'INSURED').length
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-gray-500">共 {list.length} 人</span>
|
||||
<span className="text-gray-500">社保参保 <span className="font-medium text-primary">{insuredCount}</span></span>
|
||||
<span className="text-gray-500">公积金参保 <span className="font-medium text-primary">{housingCount}</span></span>
|
||||
</div>
|
||||
<div className="relative w-48">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="搜索员工姓名"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs text-gray-500">
|
||||
<th className="pb-2 pr-4 font-medium">姓名</th>
|
||||
<th className="pb-2 pr-4 font-medium">部门</th>
|
||||
<th className="pb-2 pr-4 font-medium">职务</th>
|
||||
<th className="pb-2 pr-4 font-medium">社保状态</th>
|
||||
<th className="pb-2 pr-4 font-medium">社保基数</th>
|
||||
<th className="pb-2 pr-4 font-medium">社保城市</th>
|
||||
<th className="pb-2 pr-4 font-medium">社保起缴</th>
|
||||
<th className="pb-2 pr-4 font-medium">公积金状态</th>
|
||||
<th className="pb-2 pr-4 font-medium">公积金基数</th>
|
||||
<th className="pb-2 pr-4 font-medium">公积金城市</th>
|
||||
<th className="pb-2 pr-4 font-medium">公积金起缴</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr><td colSpan={11} className="py-8 text-center text-gray-400">加载中...</td></tr>
|
||||
) : list.length === 0 ? (
|
||||
<tr><td colSpan={11} className="py-8 text-center text-gray-400">暂无员工参保信息</td></tr>
|
||||
) : list.map((emp: any) => (
|
||||
<tr key={emp.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-2 pr-4 font-medium">{emp.name}</td>
|
||||
<td className="py-2 pr-4 text-gray-600">{emp.department || '-'}</td>
|
||||
<td className="py-2 pr-4 text-gray-600">{emp.position || '-'}</td>
|
||||
<td className="py-2 pr-4">
|
||||
{emp.socialInsStatus === 'INSURED' ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-green-600">
|
||||
<CheckCircle className="w-3.5 h-3.5" />已参保
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-gray-400">
|
||||
<XCircle className="w-3.5 h-3.5" />未参保
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4">{emp.socialInsBase ? `¥${fmt(emp.socialInsBase)}` : '-'}</td>
|
||||
<td className="py-2 pr-4 text-gray-600">{emp.socialInsCity || '-'}</td>
|
||||
<td className="py-2 pr-4 text-gray-500 text-xs">{emp.socialInsStart || '-'}</td>
|
||||
<td className="py-2 pr-4">
|
||||
{emp.housingFundStatus === 'INSURED' ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-green-600">
|
||||
<CheckCircle className="w-3.5 h-3.5" />已参保
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-gray-400">
|
||||
<XCircle className="w-3.5 h-3.5" />未参保
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4">{emp.housingFundBase ? `¥${fmt(emp.housingFundBase)}` : '-'}</td>
|
||||
<td className="py-2 pr-4 text-gray-600">{emp.housingFundCity || '-'}</td>
|
||||
<td className="py-2 pr-4 text-gray-500 text-xs">{emp.housingFundStart || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user