feat: 20260809 系统优化 - 全部28项问题修复(P0×6+P1×16+P2×6)

P0: 福利批量参保/离职证明下载防乱码/考勤模板合并Sheet/补卡修改/附件在线查看删除
P1: 分页pageSize修复/离职导出筛选/撤回删除草稿/加班费自动计算/考勤加班汇总/证据链异常详情/制度催办/模板导入Word/社保封顶保底/校验字段提示/职务字段/社保费用明细/弹窗防误关/身份证查重/证明员工下拉/培训批量
P2: 离职流程去重/社保基数覆盖输入/薪税入口改名/添加员工引导/绩效模板清理
This commit is contained in:
freedakgmail
2026-08-09 11:59:02 +08:00
parent c355a7d208
commit a2e9ba55c2
43 changed files with 2913 additions and 324 deletions
+20
View File
@@ -170,6 +170,7 @@ model Organization {
attendanceRecords AttendanceRecord[]
trainingRecords TrainingRecord[]
performanceRecords PerformanceRecord[]
performanceTemplates PerformanceTemplate[]
retirementPolicies RetirementPolicy[]
socialMonthlyProcesses SocialMonthlyProcess[]
evidenceChains EvidenceChain[]
@@ -644,11 +645,14 @@ model PerformanceRecord {
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
period String // 考核周期 YYYY-MM 或 YYYY-Q1
periodType String @default("MONTHLY") // MONTHLY/QUARTERLY/YEARLY
score Float @default(0) // 考核得分
grade String @default("B") // A/B/C/D
result String @default("QUALIFIED") // EXCELLENT/QUALIFIED/NEED_IMPROVE/UNQUALIFIED
summary String? // 考核评语
improvementPlan String? // 改进计划(不胜任时)
templateId String? // 关联绩效模板(选填)
dimensionScores Json? // 各维度得分明细 { dimensionName: score }
employeeAck Boolean @default(false)
ackDate DateTime?
reviewer String?
@@ -659,6 +663,22 @@ model PerformanceRecord {
@@index([orgId, employeeId])
}
model PerformanceTemplate {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
name String // 模板名称
description String? // 模板说明
dimensions Json // 考核维度 [{ name, weight, maxScore, description }]
gradeRules Json? // 等级规则 [{ grade, result, minScore, maxScore }]
isDefault Boolean @default(false)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId])
}
// ========== 员工端表 ==========
model Payslip {
+19
View File
@@ -3,6 +3,7 @@ import { authMiddleware, AuthRequest } from '../middleware/auth'
import { auditLog } from '../middleware/auditLog'
import { createEvidence } from '../services/evidence.service'
import prisma from '../lib/prisma'
import { sha256 } from '../lib/crypto'
import {
createEmployeeSchema,
updateEmployeeSchema,
@@ -97,6 +98,24 @@ router.get('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
}
})
// 身份证查重
router.get('/check-id-card', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const idCard = req.query.idCard as string
if (!idCard || idCard.length < 18) {
return res.json({ success: true, data: { exists: false } })
}
const hash = sha256(idCard)
const employee = await prisma.employee.findFirst({
where: { orgId: req.user!.orgId, idCardHash: hash },
select: { id: true, name: true, department: true, status: true },
})
res.json({ success: true, data: { exists: !!employee, employee } })
} catch (err) {
next(err)
}
})
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = createEmployeeSchema.parse(req.body)
@@ -146,10 +146,17 @@ router.get('/:id/download', authMiddleware, async (req: AuthRequest, res: Respon
if (!template) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
}
// 包装为 HTML 格式以确保 Word 正确打开
const htmlContent = `<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>
<style>
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; }
</style></head>
<body>${template.content}</body></html>`
const encoded = encodeURIComponent(template.name + '.doc')
res.setHeader('Content-Type', 'application/msword')
res.setHeader('Content-Type', 'application/msword; charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`)
res.send(template.content)
res.send(htmlContent)
} catch (err) {
next(err)
}
+7
View File
@@ -359,9 +359,16 @@ router.get('/terminations', authMiddleware, async (req: AuthRequest, res: Respon
const status = req.query.status as string | undefined
const department = req.query.department as string | undefined
const search = req.query.search as string | undefined
const dateFrom = req.query.dateFrom as string | undefined
const dateTo = req.query.dateTo as string | undefined
const where: any = { orgId }
if (status) where.status = status
if (dateFrom || dateTo) {
where.terminationDate = {}
if (dateFrom) where.terminationDate.gte = new Date(dateFrom)
if (dateTo) where.terminationDate.lte = new Date(dateTo + 'T23:59:59')
}
if (department || search) {
where.employee = {}
if (department) where.employee.department = department
+113 -12
View File
@@ -541,12 +541,22 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
}
const wb = XLSX.read(req.file.buffer, { type: 'buffer', cellDates: true })
const result: any = { month, attendance: 0, overtime: 0, salaryChanges: 0, socialInsChanges: 0, housingFundChanges: 0, errors: [] as string[], strategies: { '考勤记录': '覆盖(同员工同日覆盖)', '加班记录': '累加(同员工同月累加)', '薪资调整': '覆盖(关闭旧记录,新建新记录)', '社保变动': '覆盖(关闭旧记录,新建新记录)', '公积金变动': '覆盖(关闭旧记录,新建新记录)' } }
const result: any = { month, attendance: 0, overtime: 0, discipline: 0, salaryChanges: 0, socialInsChanges: 0, housingFundChanges: 0, errors: [] as string[], strategies: { '考勤记录': '覆盖(同员工同日覆盖)', '加班记录': '累加(同员工同月累加)', '违纪记录': '追加(同员工同日可多条)', '薪资调整': '覆盖(关闭旧记录,新建新记录)', '社保变动': '覆盖(关闭旧记录,新建新记录)', '公积金变动': '覆盖(关闭旧记录,新建新记录)' } }
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, monthlySalary: true, department: true, idCardHash: true } })
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e]))
const empByName = new Map(employees.map(e => [e.name, e]))
// 获取加班费配置,用于自动计算 totalPay
const otConfig = await prisma.overtimeConfig.findUnique({ where: { orgId } }) ?? { weekdayRate: 1.5, weekendRate: 2.0, holidayRate: 3.0, monthlyDays: 21.75, dailyHours: 8 }
function calcOvertimePay(monthlyWage: number, wdHours: number, weHours: number, hoHours: number) {
const hourlyWage = (monthlyWage || 0) / otConfig.monthlyDays / otConfig.dailyHours
const weekdayPay = hourlyWage * otConfig.weekdayRate * wdHours
const weekendPay = hourlyWage * otConfig.weekendRate * weHours
const holidayPay = hourlyWage * otConfig.holidayRate * hoHours
return Math.round((weekdayPay + weekendPay + holidayPay) * 100) / 100
}
function findEmp(r: any) {
const idCard = val(getField(r, '身份证号'))
if (idCard) {
@@ -556,8 +566,59 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
return empByName.get(val(getField(r, '姓名')))
}
// 考勤记录
// 考勤记录 + 加班记录(支持合并Sheet"考勤与加班"或独立Sheet
const mergedSheet = wb.Sheets['考勤与加班']
const attSheet = wb.Sheets['考勤记录']
const otSheet = wb.Sheets['加班记录']
const statusMap: any = { '正常': 'NORMAL', '迟到': 'LATE', '早退': 'EARLY_LEAVE', '缺勤': 'ABSENT', '请假': 'LEAVE', '出差': 'BUSINESS_TRIP' }
if (mergedSheet) {
// 合并Sheet:每行同时处理考勤和加班
const rows = XLSX.utils.sheet_to_json(mergedSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const date = parseDate(getField(r, '日期'))
if (!date) { result.errors.push(`${i + 2}行:日期格式错误`); continue }
// 考勤部分
const attStatus = val(getField(r, '考勤状态'))
if (attStatus || val(getField(r, '上班时间')) || val(getField(r, '下班时间'))) {
await prisma.attendanceRecord.upsert({
where: { employeeId_date: { employeeId: emp.id, date } },
create: { orgId, employeeId: emp.id, date, status: statusMap[attStatus] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null, createdBy: userId },
update: { status: statusMap[attStatus] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null },
})
result.attendance++
}
// 加班部分
const wdHours = num(getField(r, '工作日加班时长'))
const weHours = num(getField(r, '休息日加班时长'))
const hoHours = num(getField(r, '法定节假日加班时长'))
if (wdHours > 0 || weHours > 0 || hoHours > 0) {
const otMonth = dateToMonth(date)
let monthlyWage = 0
try { monthlyWage = Number(decrypt(emp.monthlySalary)) || 0 } catch { monthlyWage = Number(emp.monthlySalary) || 0 }
const totalPay = calcOvertimePay(monthlyWage, wdHours, weHours, hoHours)
await prisma.overtimeRecord.upsert({
where: { employeeId_month: { employeeId: emp.id, month: otMonth } },
create: { orgId, employeeId: emp.id, month: otMonth, weekdayHours: wdHours, weekendHours: weHours, holidayHours: hoHours, totalPay } as any,
update: {
weekdayHours: { increment: wdHours },
weekendHours: { increment: weHours },
holidayHours: { increment: hoHours },
totalPay: { increment: totalPay },
},
})
result.overtime++
}
} catch (e: any) { result.errors.push(`${i + 2}行:${e?.message || '导入失败'}`) }
}
} else {
// 向后兼容:独立Sheet
if (attSheet) {
const rows = XLSX.utils.sheet_to_json(attSheet)
for (let i = 0; i < rows.length; i++) {
@@ -567,7 +628,6 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
if (!emp) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const date = parseDate(getField(r, '日期'))
if (!date) { result.errors.push(`考勤第${i + 2}行:日期格式错误`); continue }
const statusMap: any = { '正常': 'NORMAL', '迟到': 'LATE', '早退': 'EARLY_LEAVE', '缺勤': 'ABSENT', '请假': 'LEAVE', '出差': 'BUSINESS_TRIP' }
await prisma.attendanceRecord.upsert({
where: { employeeId_date: { employeeId: emp.id, date } },
create: { orgId, employeeId: emp.id, date, status: statusMap[val(getField(r, '考勤状态'))] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null, createdBy: userId },
@@ -578,8 +638,6 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
}
}
// 加班记录
const otSheet = wb.Sheets['加班记录']
if (otSheet) {
const rows = XLSX.utils.sheet_to_json(otSheet)
for (let i = 0; i < rows.length; i++) {
@@ -595,19 +653,24 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
const wdHours = num(getField(r, '工作日加班时长')) || (otType.includes('工作日') ? hours : 0)
const weHours = num(getField(r, '休息日加班时长')) || (otType.includes('休息日') ? hours : 0)
const hoHours = num(getField(r, '法定节假日加班时长')) || (otType.includes('法定') ? hours : 0)
let monthlyWage = 0
try { monthlyWage = Number(decrypt(emp.monthlySalary)) || 0 } catch { monthlyWage = Number(emp.monthlySalary) || 0 }
const totalPay = calcOvertimePay(monthlyWage, wdHours, weHours, hoHours)
await prisma.overtimeRecord.upsert({
where: { employeeId_month: { employeeId: emp.id, month: otMonth } },
create: { orgId, employeeId: emp.id, month: otMonth, weekdayHours: wdHours, weekendHours: weHours, holidayHours: hoHours } as any,
create: { orgId, employeeId: emp.id, month: otMonth, weekdayHours: wdHours, weekendHours: weHours, holidayHours: hoHours, totalPay } as any,
update: {
weekdayHours: { increment: wdHours },
weekendHours: { increment: weHours },
holidayHours: { increment: hoHours },
totalPay: { increment: totalPay },
},
})
result.overtime++
} catch (e: any) { result.errors.push(`加班第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
}
// 薪资调整
const salarySheet = wb.Sheets['薪资调整']
@@ -685,6 +748,27 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
}
}
// 违纪记录
const discSheet = wb.Sheets['违纪记录']
if (discSheet) {
const rows = XLSX.utils.sheet_to_json(discSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`违纪第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const date = parseDate(getField(r, '日期'))
if (!date) { result.errors.push(`违纪第${i + 2}行:日期格式错误`); continue }
const typeMap: any = { '迟到': 'LATE', '旷工': 'ABSENT', '不服从': 'INSUBORDINATION', '违纪': 'MISCONDUCT', '违规': 'VIOLATE_POLICY', '其他': 'OTHER' }
const actMap: any = { '口头警告': 'ORAL_WARNING', '书面警告': 'WRITTEN_WARNING', '扣款': 'DEDUCTION', '降级': 'DEMOTION', '辞退': 'TERMINATION' }
await prisma.disciplinaryRecord.create({
data: { orgId, employeeId: emp.id, violationDate: date, violationType: typeMap[val(getField(r, '违纪类型'))] || 'OTHER', description: val(getField(r, '描述')) || '', action: actMap[val(getField(r, '处罚'))] || 'ORAL_WARNING', createdBy: userId },
})
result.discipline++
} catch (e: any) { result.errors.push(`违纪第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
res.json({ success: true, data: result })
} catch (err) {
next(err)
@@ -694,11 +778,25 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
router.get('/monthly-template', authMiddleware, async (_req: AuthRequest, res: Response) => {
const wb = XLSX.utils.book_new()
const attData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-01', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(attData), '考勤记录')
const otData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班时长': 2, '加班类型': '工作日加班' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录')
// 合并考勤+加班为一个Sheet,减少重复录入姓名身份证号
const attOtData = [{
'姓名': '张三',
'身份证号': '110101199001011234',
'日期': '2024-06-01',
'考勤状态': '正常',
'上班时间': '09:00',
'下班时间': '18:00',
'工作日加班时长': 0,
'休息日加班时长': 0,
'法定节假日加班时长': 0,
'备注': '',
}]
const attOtWs = XLSX.utils.json_to_sheet(attOtData)
attOtWs['!cols'] = [
{ wch: 10 }, { wch: 20 }, { wch: 12 }, { wch: 10 }, { wch: 8 }, { wch: 8 },
{ wch: 14 }, { wch: 14 }, { wch: 16 }, { wch: 12 },
]
XLSX.utils.book_append_sheet(wb, attOtWs, '考勤与加班')
const salaryData = [{ '姓名': '张三', '身份证号': '110101199001011234', '调整后月薪': 12000, '生效日期': '2024-06-01', '调薪原因': '年度调薪' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(salaryData), '薪资调整')
@@ -709,9 +807,12 @@ router.get('/monthly-template', authMiddleware, async (_req: AuthRequest, res: R
const hfData = [{ '姓名': '张三', '身份证号': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(hfData), '公积金变动')
const discData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(discData), '违纪记录')
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', contentDisposition('月度增减员导入模板.xlsx'))
res.setHeader('Content-Disposition', contentDisposition('考勤月度导入模板.xlsx'))
res.send(buf)
})
+82
View File
@@ -129,6 +129,88 @@ router.put('/overtime/:id', async (req: AuthRequest, res: Response, next: NextFu
}
})
// 从考勤记录同步加班工时
router.post('/overtime/sync-from-attendance', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { month } = req.body as { month: string }
if (!month || !/^\d{4}-\d{2}$/.test(month)) {
return res.status(400).json({ success: false, message: '请提供有效的月份(YYYY-MM' })
}
const monthStart = new Date(month + '-01')
const monthEnd = new Date(monthStart)
monthEnd.setMonth(monthEnd.getMonth() + 1)
// 获取该月所有考勤记录(含加班工时)
const records = await prisma.attendanceRecord.findMany({
where: { orgId, date: { gte: monthStart, lt: monthEnd }, overtimeHours: { gt: 0 } },
})
if (records.length === 0) {
return res.json({ success: false, message: '该月考勤记录中无加班工时' })
}
// 按员工汇总加班工时,按日期类型分类
const empMap = new Map<string, { weekday: number; weekend: number; holiday: number }>()
for (const r of records) {
const day = new Date(r.date)
const dayOfWeek = day.getDay() // 0=周日, 6=周六
let type: 'weekday' | 'weekend' | 'holiday' = 'weekday'
if (dayOfWeek === 0 || dayOfWeek === 6) {
type = 'weekend'
}
// 简单判断法定节假日:这里使用周末判断,实际法定节假日需要额外配置
// 如果有 holidayHours 字段在 attendanceRecord 中,优先使用
if (!empMap.has(r.employeeId)) {
empMap.set(r.employeeId, { weekday: 0, weekend: 0, holiday: 0 })
}
const entry = empMap.get(r.employeeId)!
entry[type] += r.overtimeHours || 0
}
// 获取员工月工资用于计算加班费
let config = await prisma.overtimeConfig.findUnique({ where: { orgId } })
if (!config) config = await prisma.overtimeConfig.create({ data: { orgId } })
let synced = 0
for (const [employeeId, hours] of empMap) {
const emp = await prisma.employee.findFirst({ where: { id: employeeId }, select: { monthlySalary: true } })
let monthlyWage = 0
try { monthlyWage = emp?.monthlySalary ? Number(decrypt(emp.monthlySalary)) : 0 } catch { monthlyWage = Number(emp?.monthlySalary) || 0 }
const hourlyWage = monthlyWage / config.monthlyDays / config.dailyHours
const weekdayPay = hourlyWage * config.weekdayRate * hours.weekday
const weekendPay = hourlyWage * config.weekendRate * hours.weekend
const holidayPay = hourlyWage * config.holidayRate * hours.holiday
const totalPay = weekdayPay + weekendPay + holidayPay
await prisma.overtimeRecord.upsert({
where: { employeeId_month: { employeeId, month } },
update: {
weekdayHours: hours.weekday,
weekendHours: hours.weekend,
holidayHours: hours.holiday,
weekdayPay, weekendPay, holidayPay, totalPay,
},
create: {
orgId, employeeId, month,
weekdayHours: hours.weekday,
weekendHours: hours.weekend,
holidayHours: hours.holiday,
weekdayPay, weekendPay, holidayPay, totalPay,
},
})
synced++
}
res.json({ success: true, data: { synced, totalEmployees: empMap.size } })
} catch (err) {
next(err)
}
})
// ========== 工资条管理 ==========
const payslipSchema = z.object({
+52 -5
View File
@@ -1,5 +1,6 @@
import { Router, Response, NextFunction } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { requireAdmin } from '../middleware/rbac'
import { z } from 'zod'
import prisma from '../lib/prisma'
import {
@@ -90,24 +91,26 @@ router.delete('/:id', authMiddleware, async (req: AuthRequest, res: Response, ne
router.get('/:id/read-stats', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const policy = await prisma.policyDocument.findFirst({ where: { id: req.params.id, orgId }, select: { id: true } })
const policy = await prisma.policyDocument.findFirst({ where: { id: req.params.id, orgId }, select: { id: true, title: true } })
if (!policy) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '制度不存在' } })
}
const [totalEmployees, readRecords] = await Promise.all([
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
const [allEmployees, readRecords] = await Promise.all([
prisma.employee.findMany({ where: { orgId, status: 'ACTIVE' }, select: { id: true, name: true, department: true }, orderBy: { name: 'asc' } }),
prisma.policyReadRecord.findMany({
where: { policyId: req.params.id, orgId },
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { readAt: 'desc' },
}),
])
const readEmpIds = new Set(readRecords.map(r => r.employeeId))
const unreadEmployees = allEmployees.filter(e => !readEmpIds.has(e.id))
res.json({
success: true,
data: {
total: totalEmployees,
total: allEmployees.length,
readCount: readRecords.length,
unreadCount: totalEmployees - readRecords.length,
unreadCount: unreadEmployees.length,
records: readRecords.map(r => ({
employeeId: r.employeeId,
employeeName: r.employee.name,
@@ -115,6 +118,11 @@ router.get('/:id/read-stats', authMiddleware, async (req: AuthRequest, res: Resp
readAt: r.readAt.toISOString(),
ip: r.ip,
})),
unreadEmployees: unreadEmployees.map(e => ({
employeeId: e.id,
employeeName: e.name,
department: e.department,
})),
},
})
} catch (err) {
@@ -122,4 +130,43 @@ router.get('/:id/read-stats', authMiddleware, async (req: AuthRequest, res: Resp
}
})
/** 催办未签收员工 */
router.post('/:id/remind', authMiddleware, requireAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const policy = await prisma.policyDocument.findFirst({ where: { id: req.params.id, orgId }, select: { id: true, title: true } })
if (!policy) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '制度不存在' } })
}
const { employeeIds } = req.body as { employeeIds?: string[] }
const readRecords = await prisma.policyReadRecord.findMany({ where: { policyId: req.params.id, orgId }, select: { employeeId: true } })
const readEmpIds = new Set(readRecords.map(r => r.employeeId))
const targetEmployees = await prisma.employee.findMany({
where: {
orgId, status: 'ACTIVE',
id: employeeIds && employeeIds.length > 0 ? { in: employeeIds } : undefined,
},
select: { id: true, name: true },
})
const unreadEmployees = targetEmployees.filter(e => !readEmpIds.has(e.id))
// 创建催办通知
for (const emp of unreadEmployees) {
await prisma.notificationLog.create({
data: {
orgId,
employeeId: emp.id,
type: 'POLICY_REMIND',
title: `制度签收提醒:${policy.title}`,
content: `您有一项制度「${policy.title}」尚未签收,请尽快完成阅读确认。`,
channel: 'IN_APP',
status: 'SENT',
},
}).catch(() => {})
}
res.json({ success: true, data: { reminded: unreadEmployees.length } })
} catch (err) {
next(err)
}
})
export default router
+178 -2
View File
@@ -5,6 +5,7 @@ import { createEvidence } from '../services/evidence.service'
import prisma from '../lib/prisma'
import { decrypt, encrypt } from '../lib/crypto'
import { getContractStatus } from '../services/contract.service'
import { calcSocialInsurance, calcHousingFund } from '../services/payroll.service'
import ExcelJS from 'exceljs'
const router = Router()
@@ -120,6 +121,32 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
}),
])
// 获取社保和公积金配置(按城市缓存)
const currentMonth = new Date().toISOString().slice(0, 7)
const configCache = new Map<string, { social?: any; housing?: any }>()
const getConfigsForCity = async (city?: string) => {
const key = city || '_default'
if (configCache.has(key)) return configCache.get(key)!
const cityWhere = city ? { orgId: req.user!.orgId, city } : { orgId: req.user!.orgId }
const [socialCfg, housingCfg] = await Promise.all([
prisma.socialInsuranceConfig.findFirst({
where: { ...cityWhere, effectiveFrom: { lte: currentMonth }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: currentMonth } }] },
orderBy: { effectiveFrom: 'desc' },
}),
prisma.housingFundConfig.findFirst({
where: { ...cityWhere, effectiveFrom: { lte: currentMonth }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: currentMonth } }] },
orderBy: { effectiveFrom: 'desc' },
}),
])
const result = { social: socialCfg, housing: housingCfg }
configCache.set(key, result)
return result
}
// 预加载所有涉及城市的配置
const cities = [...new Set(employees.map((e) => e.city).filter(Boolean))] as string[]
await Promise.all(cities.map((c) => getConfigsForCity(c)))
// 计算动态状态和合同状态
let result = employees.map((e) => {
const latestContract = e.contracts[0] || null
@@ -173,6 +200,20 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
idCardMasked,
idCardNumber: safeDecryptStr(e.idCardNumber),
monthlySalary: safeDecrypt(e.monthlySalary),
socialInsBase: e.socialInsBase,
housingFundBase: e.housingFundBase,
socialInsCalc: (() => {
const cfgs = configCache.get(e.city || '_default')
if (!cfgs?.social || !e.socialInsBase) return null
const r = calcSocialInsurance(e.socialInsBase, cfgs.social)
return { socialEmp: r.socialEmp, socialOrg: r.socialOrg }
})(),
housingFundCalc: (() => {
const cfgs = configCache.get(e.city || '_default')
if (!cfgs?.housing || !e.housingFundBase) return null
const r = calcHousingFund(e.housingFundBase, cfgs.housing)
return { housingEmp: r.housingEmp, housingOrg: r.housingOrg }
})(),
isPregnant: e.isPregnant,
isInMedicalPeriod: e.isInMedicalPeriod,
isWorkInjured: e.isWorkInjured,
@@ -767,7 +808,11 @@ router.get('/training/list', authMiddleware, async (req: AuthRequest, res, next)
const page = parseInt(req.query.page as string) || 1
const pageSize = parseInt(req.query.pageSize as string) || 20
const keyword = (req.query.keyword as string) || ''
const ackStatus = (req.query.ackStatus as string) || ''
const where: any = { orgId }
if (ackStatus) {
where.ackStatus = ackStatus
}
if (keyword) {
const employees = await prisma.employee.findMany({
where: { orgId, name: { contains: keyword } },
@@ -789,6 +834,35 @@ router.get('/training/list', authMiddleware, async (req: AuthRequest, res, next)
} catch (err) { next(err) }
})
// 培训记录催办(发送通知给未签收员工)
router.post('/training/remind/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const orgId = req.user!.orgId
const record = await prisma.trainingRecord.findFirst({
where: { id: req.params.recordId, orgId },
include: { employee: { select: { id: true, name: true, department: true, phone: true } } },
})
if (!record) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '培训记录不存在' } })
}
if (record.ackStatus !== 'PENDING') {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '仅待签收记录可催办' } })
}
// 记录催办通知日志
await prisma.notificationLog.create({
data: {
orgId,
type: 'TRAINING_REMIND',
title: `培训签收催办:${record.topic}`,
content: `员工 ${record.employee.name}${record.employee.department})的培训记录「${record.topic}」尚未签收,请尽快完成签收。`,
channel: 'SYSTEM',
status: 'SENT',
},
})
res.json({ success: true, data: { message: `已催办 ${record.employee.name} 签收「${record.topic}` } })
} catch (err) { next(err) }
})
// 绩效记录列表(全员)
router.get('/performance/list', authMiddleware, async (req: AuthRequest, res, next) => {
try {
@@ -1074,6 +1148,33 @@ router.post('/:employeeId/training', authMiddleware, async (req: AuthRequest, re
} catch (err) { next(err) }
})
// 批量创建培训记录
router.post('/training/batch', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { employeeIds, trainingDate, topic, content, trainer, duration, remark } = req.body
if (!employeeIds || !Array.isArray(employeeIds) || employeeIds.length === 0) {
return res.json({ success: false, error: { code: 'VALIDATION_ERROR', message: '请至少选择一名员工' } })
}
const results = await Promise.all(employeeIds.map((empId: string) =>
prisma.trainingRecord.create({
data: {
orgId: req.user!.orgId,
employeeId: empId,
trainingDate: new Date(trainingDate),
topic,
content,
trainer,
duration: duration || 0,
ackStatus: 'PENDING',
remark,
createdBy: req.user!.id,
},
})
))
res.json({ success: true, data: { count: results.length } })
} catch (err) { next(err) }
})
router.put('/:employeeId/training/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { trainingDate, topic, content, trainer, duration, ackStatus, ackDate, attachmentUrl, remark } = req.body
@@ -1124,29 +1225,35 @@ router.get('/:employeeId/performance', authMiddleware, async (req: AuthRequest,
router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { period, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer } = req.body
const { period, periodType, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer, templateId, dimensionScores } = req.body
const record = await prisma.performanceRecord.upsert({
where: { employeeId_period: { employeeId: req.params.employeeId, period } },
create: {
orgId: req.user!.orgId,
employeeId: req.params.employeeId,
period,
periodType: periodType || 'MONTHLY',
score: score || 0,
grade: grade || 'B',
result: result || 'QUALIFIED',
summary,
improvementPlan,
templateId: templateId || null,
dimensionScores: dimensionScores || undefined,
employeeAck: employeeAck || false,
ackDate: ackDate ? new Date(ackDate) : null,
reviewer,
createdBy: req.user!.id,
},
update: {
periodType,
score,
grade,
result,
summary,
improvementPlan,
templateId: templateId || null,
dimensionScores: dimensionScores || undefined,
employeeAck,
ackDate: ackDate ? new Date(ackDate) : null,
reviewer,
@@ -1159,7 +1266,7 @@ router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest,
router.put('/:employeeId/performance/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { period, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer } = req.body
const { period, periodType, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer, templateId, dimensionScores } = req.body
const record = await prisma.performanceRecord.findFirst({
where: { id: req.params.recordId, orgId: req.user!.orgId },
})
@@ -1168,11 +1275,14 @@ router.put('/:employeeId/performance/:recordId', authMiddleware, async (req: Aut
where: { id: req.params.recordId },
data: {
period,
periodType,
score,
grade,
result,
summary,
improvementPlan,
templateId: templateId || null,
dimensionScores: dimensionScores || undefined,
employeeAck,
ackDate: ackDate ? new Date(ackDate) : null,
reviewer,
@@ -1193,6 +1303,72 @@ router.delete('/:employeeId/performance/:recordId', authMiddleware, async (req:
} catch (err) { next(err) }
})
// ========== 绩效模板 CRUD ==========
// 获取模板列表
router.get('/performance/templates', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const templates = await prisma.performanceTemplate.findMany({
where: { orgId: req.user!.orgId },
orderBy: { createdAt: 'desc' },
})
res.json({ success: true, data: templates })
} catch (err) { next(err) }
})
// 创建模板
router.post('/performance/templates', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { name, description, dimensions, gradeRules, isDefault } = req.body
if (!name || !dimensions || !Array.isArray(dimensions)) {
return res.json({ success: false, error: { code: 'VALIDATION_ERROR', message: '模板名称和考核维度为必填' } })
}
// 如果设为默认,先取消其他默认
if (isDefault) {
await prisma.performanceTemplate.updateMany({ where: { orgId: req.user!.orgId, isDefault: true }, data: { isDefault: false } })
}
const template = await prisma.performanceTemplate.create({
data: {
orgId: req.user!.orgId,
name,
description,
dimensions,
gradeRules: gradeRules || undefined,
isDefault: isDefault || false,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: template })
} catch (err) { next(err) }
})
// 更新模板
router.put('/performance/templates/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { name, description, dimensions, gradeRules, isDefault } = req.body
const existing = await prisma.performanceTemplate.findFirst({ where: { id: req.params.id, orgId: req.user!.orgId } })
if (!existing) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
if (isDefault) {
await prisma.performanceTemplate.updateMany({ where: { orgId: req.user!.orgId, isDefault: true, id: { not: req.params.id } }, data: { isDefault: false } })
}
const updated = await prisma.performanceTemplate.update({
where: { id: req.params.id },
data: { name, description, dimensions, gradeRules: gradeRules || undefined, isDefault },
})
res.json({ success: true, data: updated })
} catch (err) { next(err) }
})
// 删除模板
router.delete('/performance/templates/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const existing = await prisma.performanceTemplate.findFirst({ where: { id: req.params.id, orgId: req.user!.orgId } })
if (!existing) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
await prisma.performanceTemplate.delete({ where: { id: req.params.id } })
res.json({ success: true })
} catch (err) { next(err) }
})
// ========== 调薪/调部门 API ==========
function dateToMonth(date: Date): string {
+8 -2
View File
@@ -49,10 +49,16 @@ router.get('/:id/download', authMiddleware, async (req: AuthRequest, res: Respon
if (!template) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
}
const htmlContent = `<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>
<style>
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; }
</style></head>
<body>${template.content}</body></html>`
const encoded = encodeURIComponent(template.name + '.doc')
res.setHeader('Content-Type', 'application/msword')
res.setHeader('Content-Type', 'application/msword; charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`)
res.send(template.content)
res.send(htmlContent)
} catch (err) {
next(err)
}
+20
View File
@@ -340,4 +340,24 @@ router.get('/draft/:id/validate-step', authMiddleware, async (req: AuthRequest,
}
})
// 删除草稿(仅允许 DRAFT 和 CANCELLED 状态)
router.delete('/draft/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const record = await prisma.terminationRecord.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!record) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
}
if (record.status !== 'DRAFT' && record.status !== 'CANCELLED') {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '仅草稿或已撤销的记录可以删除' } })
}
await prisma.terminationRecord.delete({ where: { id: req.params.id } })
await auditLog(req, 'DELETE_DRAFT', 'TERMINATION_RECORD', req.params.id, { employeeId: record.employeeId })
res.json({ success: true })
} catch (err) {
next(err)
}
})
export default router
+2
View File
@@ -14,6 +14,7 @@ export const createEmployeeSchema = z.object({
isWorkInjured: z.boolean().default(false),
city: z.string().max(20).optional(),
education: z.string().max(20).optional(),
position: z.string().max(50).optional(),
contract: z.object({
signDate: z.string().datetime().nullable(),
startDate: z.string().datetime(),
@@ -48,6 +49,7 @@ export const updateEmployeeSchema = z.object({
specialDeduction: z.number().min(0).optional(),
city: z.string().max(20).optional(),
education: z.string().max(20).optional(),
position: z.string().max(50).optional(),
})
export const batchRenewSchema = z.object({
+7 -6
View File
@@ -279,8 +279,8 @@ export async function manualCorrectAttendance(orgId: string, data: {
where: { orgId, employeeId: data.employeeId, date: { gte: day, lt: nextDay } },
})
const checkInTime = data.checkInTime ? new Date(`${data.date}T${data.checkInTime}`).toISOString() : null
const checkOutTime = data.checkOutTime ? new Date(`${data.date}T${data.checkOutTime}`).toISOString() : null
const checkInTime = data.checkInTime ? new Date(`${data.date}T${data.checkInTime}:00Z`).toISOString() : null
const checkOutTime = data.checkOutTime ? new Date(`${data.date}T${data.checkOutTime}:00Z`).toISOString() : null
let workHours = 0
if (checkInTime && checkOutTime) {
@@ -388,10 +388,11 @@ export async function getMonthlyReport(orgId: string, month: string) {
orderBy: { name: 'asc' },
})
const otMap = new Map<string, number>()
const otMap = new Map<string, { hours: number; pay: number }>()
for (const ot of overtimes) {
const totalHours = (ot.weekdayHours || 0) + (ot.weekendHours || 0) + (ot.holidayHours || 0)
otMap.set(ot.employeeId, (otMap.get(ot.employeeId) || 0) + totalHours)
const prev = otMap.get(ot.employeeId) || { hours: 0, pay: 0 }
otMap.set(ot.employeeId, { hours: prev.hours + totalHours, pay: prev.pay + (ot.totalPay || 0) })
}
const leaveMap = new Map<string, number>()
@@ -412,8 +413,8 @@ export async function getMonthlyReport(orgId: string, month: string) {
earlyLeaveCount: empRecords.filter(r => r.status === 'EARLY_LEAVE').length,
absentDays: empRecords.filter(r => r.status === 'ABSENT').length,
leaveDays: leaveMap.get(emp.id) || 0,
overtimeHours: confirmation ? (confirmation.weekdayHours + confirmation.weekendHours + confirmation.holidayHours) : (otMap.get(emp.id) || 0),
overtimePay: confirmation?.overtimePay || 0,
overtimeHours: confirmation ? (confirmation.weekdayHours + confirmation.weekendHours + confirmation.holidayHours) : (otMap.get(emp.id)?.hours || 0),
overtimePay: confirmation?.overtimePay || otMap.get(emp.id)?.pay || 0,
confirmationStatus: confirmation?.status || null,
}
})
+49 -6
View File
@@ -13,6 +13,24 @@ function dateToMonth(date: Date): string {
return `${y}-${m}`
}
async function clampSocialInsBase(orgId: string, base: number, city?: string): Promise<number> {
const config = await prisma.socialInsuranceConfig.findFirst({
where: { orgId, ...(city ? { city } : {}) },
orderBy: { effectiveFrom: 'desc' },
})
if (config) return Math.min(Math.max(base, config.baseMin), config.baseMax)
return base
}
async function clampHousingFundBase(orgId: string, base: number, city?: string): Promise<number> {
const config = await prisma.housingFundConfig.findFirst({
where: { orgId, ...(city ? { city } : {}) },
orderBy: { effectiveFrom: 'desc' },
})
if (config) return Math.min(Math.max(base, config.baseMin), config.baseMax)
return base
}
function prevMonth(month: string): string {
const [y, m] = month.split('-').map(Number)
const d = new Date(y, m - 2, 1)
@@ -191,6 +209,17 @@ export async function getEmployeeDetail(orgId: string, id: string) {
}
export async function createEmployee(orgId: string, userId: string, data: any) {
// 身份证号查重
if (data.idCardNumber) {
const existing = await prisma.employee.findFirst({
where: { orgId, idCardHash: sha256(data.idCardNumber) },
select: { id: true, name: true, department: true, status: true },
})
if (existing) {
throw { code: 'DUPLICATE_ID_CARD', message: `身份证号已存在:${existing.name}${existing.department}${existing.status === 'ACTIVE' ? '在职' : '离职'}),请确认是否重复录入` }
}
}
const org = await prisma.organization.findUnique({ where: { id: orgId } })
if (org && org.maxEmployees > 0) {
const activeCount = await prisma.employee.count({ where: { orgId, status: 'ACTIVE' } })
@@ -202,8 +231,11 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
const hireDate = new Date(data.hireDate)
const hireMonth = dateToMonth(hireDate)
const salaryNum = Number(data.monthlySalary) || 0
const socialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
const housingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
const city = data.city || '北京'
const rawSocialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
const rawHousingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
const socialInsBase = await clampSocialInsBase(orgId, rawSocialInsBase, city)
const housingFundBase = await clampHousingFundBase(orgId, rawHousingFundBase, city)
const socialInsStartMonth = data.socialInsStartMonth || hireMonth
const housingFundStartMonth = data.housingFundStartMonth || hireMonth
@@ -230,6 +262,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
createdBy: userId,
city: data.city || '北京',
education: data.education || null,
position: data.position || null,
},
})
@@ -346,8 +379,11 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
const newHireMonth = dateToMonth(newHireDate)
const salaryNum = Number(decrypt(employee.monthlySalary)) || 0
const socialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
const housingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
const city = data.city || employee.city || '北京'
const rawSocialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
const rawHousingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
const socialInsBase = await clampSocialInsBase(orgId, rawSocialInsBase, city)
const housingFundBase = await clampHousingFundBase(orgId, rawHousingFundBase, city)
const socialInsStartMonth = data.socialInsStartMonth || newHireMonth
const housingFundStartMonth = data.housingFundStartMonth || newHireMonth
const prevHireMonth = prevMonth(newHireMonth)
@@ -543,11 +579,18 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
if (data.isPregnant !== undefined) updateData.isPregnant = data.isPregnant
if (data.isInMedicalPeriod !== undefined) updateData.isInMedicalPeriod = data.isInMedicalPeriod
if (data.isWorkInjured !== undefined) updateData.isWorkInjured = data.isWorkInjured
if (data.socialInsBase !== undefined) updateData.socialInsBase = data.socialInsBase
if (data.housingFundBase !== undefined) updateData.housingFundBase = data.housingFundBase
if (data.socialInsBase !== undefined) {
const city = data.city || employee.city || '北京'
updateData.socialInsBase = await clampSocialInsBase(orgId, Number(data.socialInsBase), city)
}
if (data.housingFundBase !== undefined) {
const city = data.city || employee.city || '北京'
updateData.housingFundBase = await clampHousingFundBase(orgId, Number(data.housingFundBase), city)
}
if (data.specialDeduction !== undefined) updateData.specialDeduction = data.specialDeduction
if (data.city !== undefined) updateData.city = data.city
if (data.education !== undefined) updateData.education = data.education
if (data.position !== undefined) updateData.position = data.position
// 参保城市变更:关闭旧城市在保记录,创建新城市记录
if (data.city !== undefined && data.city !== employee.city) {
+12 -3
View File
@@ -185,6 +185,7 @@ export async function verifyAllEvidence(orgId: string) {
const records = await prisma.evidenceChain.findMany({ where: { orgId } })
let valid = 0
let invalid = 0
const invalidItems: any[] = []
for (const r of records) {
const events = r.events as any[]
const eventsJson = JSON.stringify(events)
@@ -198,8 +199,16 @@ export async function verifyAllEvidence(orgId: string) {
})
const sortedJson = JSON.stringify(sortedEvents)
const sortedHash = sha256(sortedJson + orgId + r.category + (r.refId || ''))
if (sortedHash === r.hash) valid++
else invalid++
if (sortedHash === r.hash) { valid++; continue }
invalid++
invalidItems.push({
id: r.id,
category: r.category,
refId: r.refId,
employeeId: r.employeeId,
createdAt: r.createdAt.toISOString(),
description: `证据链 ${r.category}${r.refId ? `(${r.refId})` : ''} 哈希校验失败,可能被篡改`,
})
}
return { total: records.length, valid, invalid }
return { total: records.length, valid, invalid, invalidItems }
}
+24 -22
View File
@@ -260,29 +260,31 @@ export async function generateDocument(type: string, formData: any, orgName: str
}
}
const wrapHtml = (title: string, body: string) => `<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}</title>
<style>
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: center; }
.title { font-size: 22pt; font-weight: bold; margin-bottom: 30pt; }
.body { text-align: justify; text-indent: 2em; margin: 0 20pt; }
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; }
</style></head>
<body>
<div class="title">${title}</div>
${body}
</body></html>`
const templates: Record<string, (data: any, org: string) => string> = {
INCOME_CERT: (data, org) => `收入证明
兹证明 ${data.employeeName || '___'}(身份证号:${data.idCardNumber || '___'})系我单位员工,自 ${data.hireDate || '___'} 起在我单位工作,现任 ${data.position || '___'} 职务。
该员工近一年平均月收入为人民币 ${data.monthlyIncome || '___'} 元(税前)。
本证明仅用于 ${data.purpose || '___'},不作其他用途。
特此证明。
${org}
${new Date().toLocaleDateString('zh-CN')}`,
LEAVING_CERT: (data, org) => `离职证明
兹证明 ${data.employeeName || '___'}(身份证号:${data.idCardNumber || '___'})自 ${data.hireDate || '___'}${data.leaveDate || '___'} 在我单位工作,最后职务为 ${data.position || '___'}
该员工已于 ${data.leaveDate || '___'} 与我单位解除劳动关系,双方已办妥交接手续。
特此证明。
${org}
${new Date().toLocaleDateString('zh-CN')}`,
INCOME_CERT: (data, org) => wrapHtml('收入证明', `
<div class="body">兹证明 ${data.employeeName || '___'}(身份证号:${data.idCardNumber || '___'})系我单位员工,自 ${data.hireDate || '___'} 起在我单位工作,现任 ${data.position || '___'} 职务。</div>
<div class="body">该员工近一年平均月收入为人民币 ${data.monthlyIncome || '___'} 元(税前)。</div>
<div class="body">本证明仅用于 ${data.purpose || '___'},不作其他用途。</div>
<div class="body">特此证明。</div>
<div class="sign">${org}<br/>${new Date().toLocaleDateString('zh-CN')}</div>`),
LEAVING_CERT: (data, org) => wrapHtml('离职证明', `
<div class="body">兹证明 ${data.employeeName || '___'}(身份证号:${data.idCardNumber || '___'})自 ${data.hireDate || '___'}${data.leaveDate || '___'} 在我单位工作,最后职务为 ${data.position || '___'}。</div>
<div class="body">该员工已于 ${data.leaveDate || '___'} 与我单位解除劳动关系,双方已办妥交接手续。</div>
<div class="body">特此证明。</div>
<div class="sign">${org}<br/>${new Date().toLocaleDateString('zh-CN')}</div>`),
}
const generator = templates[type]
if (!generator) return { name: '', content: '' }
+867
View File
@@ -0,0 +1,867 @@
# 20260809 优化需求清单
> 基于用户反馈整理,共 28 项问题,按模块和优先级分类。
>
> **代码审查更新**2026-08-09 完成全量代码核查,补充实际代码定位和确认结果。
---
## 一、员工福利模块
### 问题1:福利方案创建后无法添加享受人员,批量参保无人员数据
**模块**:员工福利
**优先级**P0
**状态**:待验证
**现状描述**
创建好福利方案后,无法增加享受福利的人员,批量参保时无人员数据可选。
**代码核查结果**
功能实际已实现。`EmployeeBenefits.tsx` 中有完整的批量参保功能:
- 点击福利方案卡片可展开参保人员列表(`EmployeeBenefits.tsx:228`
- 「批量参保」按钮打开 Modal,加载花名册在职员工列表(`:232`
- 支持全选/勾选员工,设置生效月份,提交参保(`:395-456`
- `rosterApi.list` 查询 `pageSize: 200` 条员工数据(`:78`
**潜在问题**`rosterData` 查询仅在 `showEnrollModal` 为 true 时启用(`enabled: showEnrollModal`),如果员工超过200人则无法全部加载。建议改用不分页的 `allLite` 接口。
**涉及文件**
- `frontend/src/pages/EmployeeBenefits.tsx` 福利方案和批量参保
- `frontend/src/lib/api-services.ts` benefitApi 定义
- `backend/src/routes/benefits.routes.ts`
**优化方案**
1. 批量参保的员工列表改用 `allLite` 接口,避免200条限制
2. 增加按部门筛选功能
3. 验证实际运行时员工列表是否正常加载
---
## 二、全局通用问题
### 问题2:多个模块中每页条数选择无反应
**模块**:全局(花名册、薪税、考勤等多个列表页)
**优先级**P1
**状态**:待验证
**现状描述**
多个模块列表页底部的「每页条数」选择器点击后无反应,无法切换每页显示条数。
**代码核查结果**
`usePageSize` hook`frontend/src/hooks/usePageSize.ts:1-18`)通过 `localStorage` 持久化,并通过 `page-size-changed` 自定义事件实现跨页面响应。`Pagination` 组件(`frontend/src/components/ui/Pagination.tsx:44-53`)在 `onPageSizeChange` 时触发回调。
**疑似问题**:多个列表页在 `onPageSizeChange` 回调中仅调用 `setPage(1)` 但未显式传递新的 `pageSize` 值。例如 `Evidence.tsx:132`
```tsx
onPageSizeChange={() => setPage(1)}
```
由于 `usePageSize` hook 返回的 `pageSize` 是全局状态,变更后自动触发 queryKey 变化,理论上应该能工作。需实际运行验证事件监听是否在所有页面正确触发重渲染。
**涉及文件**
- `frontend/src/hooks/usePageSize.ts:1-18` 全局 pageSize 状态管理
- `frontend/src/lib/pageSize.ts:1-21` getPageSize/setPageSize 工具函数
- `frontend/src/components/ui/Pagination.tsx:44-53` 分页组件
- `frontend/src/pages/Settings.tsx:15-245` 全局设置页
- `frontend/src/pages/AuditLog.tsx:118-246` 使用示例
- `frontend/src/pages/Evidence.tsx:127-133` 疑似问题点
**优化方案**
1. 验证 `usePageSize``page-size-changed` 事件是否在所有页面正确触发
2. 确保所有列表页 `onPageSizeChange` 回调中 `setPage(1)` 后 queryKey 包含 `pageSize`
3. 全局统一分页组件,确保所有列表页行为一致
---
## 三、离职管理模块
### 问题3:离职证明下载内容为乱码
**模块**:离职管理
**优先级**P0
**状态**:待修复
**现状描述**
离职管理中下载的离职证明文件内容是一团乱码,无法正常阅读。
**问题分析**
- `work-process.service.ts` 生成的 `.doc` 文件为纯文本格式,Word 打开时可能出现编码问题
- 文件下载时 `Content-Type` 和编码声明可能不正确
- 前端下载方式可能未正确处理二进制流
**涉及文件**
- `backend/src/services/work-process.service.ts:246-290` .doc 文件生成
- `backend/src/routes/work-process.routes.ts` 下载接口
- `frontend/src/pages/WorkProcess.tsx` 下载逻辑
- `frontend/src/pages/Termination.tsx:330-400` 离职管理页面
**优化方案**
1. 在生成的 `.doc` 内容头部添加 BOM 标记(`\uFEFF`),确保 Word 正确识别 UTF-8 编码
2. 后端下载接口设置正确的 `Content-Type: application/msword; charset=utf-8`
3. 前端下载时使用 Blob 并指定编码
4. 考虑生成 HTML 格式的 Word 文件(带 `xmlns:o` 命名空间),确保格式正确
---
### 问题4:离职管理导出数据缺少筛选条件
**模块**:离职管理
**优先级**P1
**状态**:待优化
**现状描述**
离职管理导出数据时一次性导出全部数据,无法按时间范围等条件筛选导出。
**问题分析**
- 导出接口未接收前端筛选参数,直接查询全部离职记录
- 前端导出按钮未传递当前筛选条件
**涉及文件**
- `backend/src/routes/export.routes.ts` 导出接口(含 terminations 导出)
- `backend/src/routes/termination.routes.ts` 离职路由
- `frontend/src/pages/Termination.tsx:330-400` 导出按钮
- `frontend/src/lib/api-services.ts:613-696` terminationApi 定义
**优化方案**
1. 导出接口增加 `dateFrom``dateTo``department``status` 等查询参数
2. 前端导出时携带当前筛选条件
3. 增加导出确认弹窗,显示筛选范围和预计条数
---
### 问题5:已提交的离职数据无法撤回,已撤回的无用数据无法删除
**模块**:离职管理
**优先级**P1
**状态**:待修复
**现状描述**
离职管理中已提交的数据无法撤回操作,已撤回的无用数据无法删除清理。
**代码核查结果**
- 后端 `termination.service.ts:256-334``revokeTermination` 方法,路由 `termination.routes.ts:81-115``DELETE /:id/revoke` 端点
- 前端 `api-services.ts:613-696``revoke` 方法定义
- **但前端 `Termination.tsx` 页面未暴露撤回和删除草稿的按钮**——UI 缺少对应操作入口
- 后端有 `cancelTermination``termination.service.ts:729-954`)和 `getDrafts` 方法
**涉及文件**
- `backend/src/services/termination.service.ts:256-334` revokeTermination
- `backend/src/services/termination.service.ts:729-954` cancelTermination, getDrafts
- `backend/src/routes/termination.routes.ts:81-115` 撤回路由
- `backend/src/routes/termination.routes.ts:128-234` 草稿管理路由
- `frontend/src/lib/api-services.ts:613-696` terminationApi.revoke/cancel
- `frontend/src/pages/Termination.tsx:330-400` **缺少撤回/删除按钮**
**优化方案**
1. 前端 `Termination.tsx` 为已提交但未完成的离职流程增加「撤回」按钮
2. 已撤回的草稿数据允许删除,增加二次确认
3. 已完成离职的记录保留不可删除(合规要求)
---
### 问题6:用工办理中离职/解聘与离职管理模块重复
**模块**:用工办理 / 离职管理
**优先级**P2
**状态**:待优化
**现状描述**
用工办理中有员工离职、解聘功能,同时还有独立的离职管理模块,功能重复,显得混乱。
**代码核查结果**
- `WorkProcess.tsx:37-40` 包含 `TERMINATE`(合同终止)、`RESCIND`(合同解除)、`LEAVING_CERT`(离职证明)等流程类型
- `Termination.tsx` 是独立的离职管理页面,含草稿管理、审批、执行等完整流程
- 两个入口功能确实重叠
**涉及文件**
- `frontend/src/pages/WorkProcess.tsx:37-40` 流程类型定义
- `frontend/src/pages/Termination.tsx:330-400` 离职管理页面
- `frontend/src/components/layout/SidebarNav.tsx`
**优化方案**
1. 用工办理中保留「入职办理」「转正」「调岗」等入职相关流程
2. 离职、解聘相关流程统一归入「离职管理」模块
3. 侧边栏菜单分组明确:用工办理(入职类)→ 离职管理(离职类)
---
## 四、考勤管理模块
### 问题7:考勤导入模板包含无关Sheet,且加班/违纪/考勤三个Sheet需合并
**模块**:考勤管理
**优先级**P0
**状态**:待优化
**现状描述**
导入考勤的模板包含「员工信息」和「劳动合同」两个无关 Sheet,只录入考勤信息无法导入。加班记录、违纪记录、考勤记录三个 Sheet 录入同一人员时需重复粘贴姓名与身份证号,应合并。
**代码核查结果**
- `backend/src/routes/import.routes.ts:494-692` 模板下载接口生成包含:员工信息、劳动合同、考勤记录、加班记录、违纪记录等多个 Sheet
- `gen_import_sample.py:50-73` Python 脚本也生成了包含多余 Sheet 的示例文件
- 导入接口 `import.routes.ts` 处理 `考勤记录``加班记录``违纪记录``薪资调整``社保变动``公积金变动` 等多个 Sheet
- **确认**:模板确实包含无关的员工信息和劳动合同 Sheet
**涉及文件**
- `backend/src/routes/import.routes.ts:494-692` 模板下载和导入处理
- `frontend/src/pages/Attendance.tsx:525-610` 前端导入弹窗
- `gen_import_sample.py:50-73` 示例文件生成脚本
**优化方案**
1. 考勤导入模板只保留考勤相关 Sheet,移除员工信息和劳动合同 Sheet
2. 将考勤记录、加班记录合并为一个 Sheet,用列区分(日期、班次、签到时间、签退时间、加班时长等)
3. 违纪记录因字段差异较大,可保留独立 Sheet 或独立导入入口
4. 每项业务(考勤、加班、违纪)提供独立的专用模板下载
---
### 问题8:补卡无法修改未打卡状态,签到签退时间显示有问题
**模块**:考勤管理 - 每日出勤
**优先级**P0
**状态**:待修复
**现状描述**
考勤排班中每日出勤页面,操作补卡时无法修改未打卡状态,且签到与签退的时间显示有异常。
**代码核查结果**
- `backend/src/services/attendance.service.ts:264-360``manualCorrectAttendance` 方法支持更新考勤记录,可设置签到/签退时间和状态
- `backend/src/routes/attendance.routes.ts:210-233``POST /manual-correct` 端点
- `frontend/src/pages/Attendance.tsx:970-1174` 的 DailyTab 有补卡弹窗和按钮
- `frontend/src/lib/api-services.ts:247-297``manualCorrect` API 调用
- 考勤状态常量定义在 `Attendance.tsx:25-33`NORMAL/LATE/EARLY_LEAVE/ABSENT/LEAVE/BUSINESS_TRIP/UNREGISTERED
- **需确认**:补卡弹窗是否限制了状态选项(未覆盖 UNREGISTERED→其他状态的修正),以及时间格式化是否有时区问题
**涉及文件**
- `frontend/src/pages/Attendance.tsx:25-33` 状态常量定义
- `frontend/src/pages/Attendance.tsx:970-1174` DailyTab 补卡弹窗
- `frontend/src/lib/api-services.ts:247-297` attendanceApi.manualCorrect
- `backend/src/services/attendance.service.ts:264-360` manualCorrectAttendance
- `backend/src/routes/attendance.routes.ts:210-233` 补卡路由
**优化方案**
1. 补卡弹窗允许修改所有考勤状态(包括未打卡→已打卡/请假/出差等)
2. 检查时间字段的时区处理,确保显示本地时间
3. 签到签退时间统一格式化为 `HH:mm` 格式
---
### 问题9:加班费计算与考勤不关联,需重复导入
**模块**:考勤管理 / 薪税管理
**优先级**P1
**状态**:待优化
**现状描述**
加班费计算时需要再导入一遍考勤数据,与考勤管理模块的数据不关联。
**问题分析**
- 加班费计算模块可能独立于考勤管理,未从已有的考勤记录中读取加班时长
- 考勤管理中的加班数据未传递到薪税计算的加班费环节
**涉及文件**
- `frontend/src/pages/money/` 加班费相关组件
- `backend/src/routes/payroll2.routes.ts` 加班费计算逻辑
- `backend/src/routes/attendance.routes.ts` 考勤数据查询
- `backend/src/routes/import.routes.ts` 考勤导入(含加班记录 Sheet
**优化方案**
1. 加班费计算改为从考勤管理模块读取已确认的加班记录
2. 薪税批次创建时自动拉取当月考勤加班数据,无需重复导入
3. 保留手动导入作为备选方案
---
### 问题10:个人考勤记录添加后加班汇总不显示
**模块**:考勤管理
**优先级**P1
**状态**:待修复
**现状描述**
个人考勤记录添加时手动填写了加班时长,但加班汇总中不显示条数,不清楚加班汇总关联的是哪里。
**问题分析**
- 加班汇总可能统计的是考勤导入的加班数据,而非手动添加的加班时长
- 加班汇总的数据源与个人考勤记录的加班字段未关联
**涉及文件**
- `frontend/src/pages/Attendance.tsx:970-1174` 加班汇总和考勤记录
- `backend/src/routes/attendance.routes.ts` 加班统计接口
**优化方案**
1. 加班汇总统计应包含手动添加的考勤记录中的加班时长
2. 加班汇总增加数据来源标识(导入/手动添加)
3. 明确加班汇总与考勤记录的关联关系,UI 上增加说明
---
## 五、证据链模块
### 问题11:验证全部完整性功能简陋,无法定位异常
**模块**:证据链
**优先级**P1
**状态**:待优化
**现状描述**
证据链中「验证全部完整性」功能验证后显示异常,但无法告知哪部分异常,下方提醒也无法跳转操作。
**代码核查结果**
- `frontend/src/pages/Evidence.tsx:31-37` 调用 `evidenceApi.verifyAll()`,返回结果仅显示 `total``valid``invalid` 三个数字(`:60-75`
- 无详细异常项列表,无跳转操作
- `frontend/src/lib/api-services.ts:700-707` `evidenceApi` 定义了 `list``verifyAll` 方法
- `frontend/src/pages/roster/EvidenceChain.tsx:1-155` 是员工个人维度的仲裁证据链,展示证据列表、风险提醒和导出功能
**涉及文件**
- `frontend/src/pages/Evidence.tsx:31-75` 验证全部完整性功能
- `frontend/src/lib/api-services.ts:700-707` evidenceApi 定义
- `frontend/src/pages/roster/EvidenceChain.tsx:1-155` 员工个人证据链
- `backend/src/routes/roster.routes.ts` 证据链验证接口
**优化方案**
1. 验证接口返回详细的检查项列表(每项:名称、状态、异常描述)
2. 前端展示验证结果明细,异常项高亮显示
3. 每个异常项增加「去处理」跳转按钮,跳转到对应模块
---
## 六、规章制度管理
### 问题12:规章制度签收缺少催办和未签收人员查看
**模块**:规章制度
**优先级**P1
**状态**:待优化
**现状描述**
规章制度向员工公示后,签收只显示签收人数和占比,无法查看具体未签收人员,也无法催办。
**代码核查结果**
- `frontend/src/pages/Policies.tsx:249-285``ReadStats` 组件,展示签收百分比和未签收人数
- 已签收人员列表可展开查看(`:278-285`),显示姓名、部门、签收时间
- **缺少催办通知功能**——无催办按钮
- **未签收人员列表未展示**——仅显示未签收人数(`:273-277`),未列出具体人员
- `frontend/src/lib/api-services.ts:674-696` `policiesApi.readStats` 返回 `readCount``total``unreadCount``records`
- 员工端 `frontend/src/pages/portal/MyPolicies.tsx:36-46` 有阅读确认 mutation 和待签收数量统计
**涉及文件**
- `frontend/src/pages/Policies.tsx:110-120` 签收进度条
- `frontend/src/pages/Policies.tsx:246-285` ReadStats 组件
- `frontend/src/lib/api-services.ts:674-696` policiesApi 定义
- `frontend/src/pages/portal/MyPolicies.tsx:30-50` 员工端阅读确认
- `backend/src/routes/regulations.routes.ts`
**优化方案**
1. 签收统计增加「查看明细」按钮,展开已签收/未签收人员列表
2. 未签收人员列表支持「一键催办」,发送通知提醒员工签收
3. 显示每位员工的签收状态和时间
---
## 七、文本模板模块
### 问题13:新建模板不支持导入文档,现有方式易造成格式混乱
**模块**:文本模板
**优先级**P1
**状态**:待优化
**现状描述**
文本模板新建时只能手动输入内容,无法通过导入 Word 文档创建,现有方式容易造成格式混乱,需要保留导入文档的原始格式。
**代码核查结果**
- `frontend/src/pages/Templates.tsx:303-571``EnterpriseTemplates` 组件中,新建模板仅支持 `textarea` 手动输入内容(`:493-498`
- 模板内容使用 `{{变量名}}` 占位符,支持变量替换渲染
- 系统模板支持下载 Word`.doc` 格式),通过 `fetch` 请求 `/templates/:id/download`
- `frontend/src/lib/api-services.ts:814-839` `templatesApi` 无文档导入接口
- **确认**:无文档上传入口,不支持导入 `.docx` 文件
**涉及文件**
- `frontend/src/pages/Templates.tsx:1-571` 模板管理页面(系统模板+企业模板)
- `frontend/src/lib/api-services.ts:814-839` templatesApi 定义
- `backend/src/routes/templates.routes.ts`
**优化方案**
1. 新建模板增加「导入文档」入口,支持上传 `.docx` 文件
2. 后端使用 `mammoth` 或类似库解析 Word 文档,保留段落、表格等结构
3. 导入后转为 HTML 存储模板内容,前端预览时保留格式
4. 保留现有手动创建方式作为备选
---
## 八、花名册模块
### 问题14:录入工资后社保基数自动取工资数,选择参保地后未自动封上下限
**模块**:花名册
**优先级**P1
**状态**:待优化
**现状描述**
花名册单独录入员工时,社保基数自动取工资数可以,但如果选择参保地,计算时未能自动封上下限。
**代码核查结果**
- `frontend/src/pages/roster/modals.tsx:680-681` 社保基数默认取月工资:`value={form.socialInsBase || form.monthlySalary}`
- `socialInsuranceApi.cities()` 已获取城市列表(`modals.tsx:493-498`
- `socialInsuranceApi.calculate(base, city)` 可计算社保费用(`api-services.ts:510-512`
- **确认**:未根据参保城市查询基数上下限进行封顶/封底处理
- `frontend/src/pages/roster/BasicInfo.tsx:74` 显示社保基数,编辑时为普通输入框(`:344-345`
**涉及文件**
- `frontend/src/pages/roster/modals.tsx:486-767` AddEmployeeModal 社保基数填充
- `frontend/src/pages/roster/modals.tsx:235-484` RehireModal 社保基数填充
- `frontend/src/pages/roster/BasicInfo.tsx:60-120` 编辑表单
- `frontend/src/lib/api-services.ts:510-520` socialInsuranceApi
**优化方案**
1. 选择参保地后,自动查询该城市的社保基数上下限
2. 社保基数 = min(max(工资数, 下限), 上限)
3. 如果工资数在上下限范围内,直接取工资数;否则显示封顶/封底后的值并提示
---
### 问题15:社保基数手动修改时原有数据不能直接覆盖
**模块**:花名册
**优先级**P2
**状态**:待修复
**现状描述**
社保基数自动取工资后实际不是社保基数时需要手动修改,但修改时原有数据不能删除,必须用鼠标点击选中后再修改,影响录入效率。
**代码核查结果**
- `frontend/src/pages/roster/modals.tsx:681` 使用 `value={form.socialInsBase || form.monthlySalary}`,当 `socialInsBase` 为空时回退到 `monthlySalary`
- 用户清空输入框时 `socialInsBase` 变为空字符串,又回退到 `monthlySalary`,无法真正清空
- **缺少 `onFocus={(e) => e.target.select()}` 聚焦全选功能**
- `BasicInfo.tsx:344-345` 编辑模式下的社保基数输入框为普通 `Input`,无自动回退问题
**涉及文件**
- `frontend/src/pages/roster/modals.tsx:680-681` AddEmployeeModal 社保基数输入框
- `frontend/src/pages/roster/modals.tsx:397-398` RehireModal 社保基数输入框
- `frontend/src/pages/roster/BasicInfo.tsx:344-345` 编辑表单社保基数输入框
**优化方案**
1. 社保基数输入框改为受控组件,自动填充后用户可直接输入覆盖
2. 输入框获得焦点时自动全选当前值,方便直接覆盖
3. 增加 `onFocus={(e) => e.target.select()}` 实现聚焦全选
---
### 问题16:录入校验失败未指明具体字段
**模块**:花名册
**优先级**P1
**状态**:待优化
**现状描述**
录入员工时可能是手机号录入有问题,但系统只提示「校验失败」,不指出哪个字段校验失败。
**代码核查结果**
- `backend/src/schemas/contract.schema.ts:3-27` `createEmployeeSchema` 定义了字段级 Zod 校验规则,如 `phone: z.string().regex(/^1[3-9]\d{9}$/)`
- 前端 `modals.tsx:648-650` 错误处理仅显示通用消息:`{error.response?.data?.error?.message || '操作失败'}`
- **未解析 Zod 返回的字段级错误信息并在对应字段下方显示**
- `backend/src/middleware/errorHandler.ts:27-31` P2002 唯一约束错误返回通用"数据已存在,请勿重复操作"
**涉及文件**
- `backend/src/schemas/contract.schema.ts:1-72` Zod 校验 schema 定义
- `backend/src/middleware/errorHandler.ts:27-31` 错误处理中间件
- `backend/src/routes/employee.routes.ts:99-126` 创建/更新员工路由
- `frontend/src/pages/roster/modals.tsx:648-650` AddEmployeeModal 错误提示
- `frontend/src/pages/roster/BasicInfo.tsx:82-119` 编辑表单错误处理
**优化方案**
1. 后端校验失败时返回具体字段名和错误原因(如 `{"field": "phone", "message": "手机号格式不正确"}`
2. 前端解析错误信息,在对应字段下方显示红色提示
3. toast 提示中包含具体字段名
---
### 问题17:花名册员工详情中薪税入口意义不明
**模块**:花名册
**优先级**P2
**状态**:待优化
**现状描述**
花名册员工个人详情中的小标识第二个点进去直接进入薪税模块(批次发薪),不理解放在员工个人这里的意义,应该是与此员工有关的个人薪资关联。
**代码核查结果**
- `frontend/src/pages/roster/EmployeeProfile.tsx:66``payslip` tab 展示 `PayslipSocialInfo`,显示该员工的工资条和社保记录
- `EmployeeProfileShell.tsx:12-17` 员工 profile 类型定义包含 `position` 字段
- 需确认是否有跳转到薪税批次列表页的入口
**涉及文件**
- `frontend/src/pages/roster/EmployeeProfile.tsx:60-71` tab 定义
- `frontend/src/pages/roster/EmployeeProfileShell.tsx:12-17` profile 类型
- `frontend/src/pages/roster/BasicInfo.tsx` 快捷入口
**优化方案**
1. 改为跳转到该员工的个人薪资历史记录页面
2. 或在员工详情中增加「薪资历史」标签页,展示该员工所有批次的工资条
---
### 问题18:花名册列表有职务列,但录入时无职务字段
**模块**:花名册
**优先级**P1
**状态**:待修复
**现状描述**
花名册主页显示有职务这一栏,但单独录入员工时却没有职务这一项。
**代码核查结果(确认)**
- 花名册列表 `Roster.tsx:29``position` 列(职务),`:500` 有表头,`:560` 有数据渲染
- `AddEmployeeModal``modals.tsx:486-767`)表单中**无 `position` 字段**
- `BasicInfo.tsx` 编辑表单中也**无 `position` 字段**
- `createEmployeeSchema``contract.schema.ts:3-27`)中**无 `position` 字段**
- `updateEmployeeSchema``contract.schema.ts:29-51`)中也**无 `position` 字段**
- 后端 `createEmployee``contract.service.ts:193-272`)中也**未设置 `position` 字段**
- **但后端查询时 select 包含 `position`**`employee.routes.ts:56,82`),说明数据库有此字段
- `EmployeeProfileShell.tsx:15` 类型定义包含 `position``:147-149` 显示 position
**涉及文件**
- `frontend/src/pages/Roster.tsx:29,500,560` 列表显示职务列
- `frontend/src/pages/roster/modals.tsx:486-767` AddEmployeeModal **缺少 position 字段**
- `frontend/src/pages/roster/BasicInfo.tsx:60-120` 编辑表单 **缺少 position 字段**
- `backend/src/schemas/contract.schema.ts:3-51` **缺少 position 字段**
- `backend/src/services/contract.service.ts:193-272` createEmployee **未设置 position**
- `backend/src/routes/employee.routes.ts:56,82` 查询时 select 包含 position
- `frontend/src/pages/roster/EmployeeProfileShell.tsx:15,147-149` profile 显示 position
**优化方案**
1. `AddEmployeeModal``BasicInfo` 编辑表单增加「职务」字段
2. `createEmployeeSchema``updateEmployeeSchema` 增加 `position: z.string().max(50).optional()`
3. `createEmployee``updateEmployee` 服务中设置 `position` 字段
---
### 问题19:花名册中社保费用计算与社保模块不一致
**模块**:花名册 / 社保管理
**优先级**P1
**状态**:待修复
**现状描述**
花名册里员工个人计算的社保费用与社保模块中不一致。社保模块里已修改了养老医保基数不一致,但花名册里计算还是保持一致。
**代码核查结果**
- `BasicInfo.tsx:325-330` 显示社保缴费基数和公积金缴费基数,使用统一基数
- `BasicInfo.tsx:364-368` 未设置基数时显示警告提示
- `contract.service.ts:205-206` 创建员工时 `socialInsBase``housingFundBase` 均默认取 `salaryNum`
- `api-services.ts:510-512` `socialInsuranceApi.calculate(base, city)` 使用统一 base 计算
- **确认**:花名册使用统一基数,未读取社保模块中按险种分别配置的基数
**涉及文件**
- `frontend/src/pages/roster/BasicInfo.tsx:320-370` 社保费用显示和编辑
- `backend/src/services/contract.service.ts:205-206` 创建员工时社保基数设置
- `frontend/src/lib/api-services.ts:510-520` socialInsuranceApi
- `backend/src/routes/social.routes.ts` 社保配置查询
**优化方案**
1. 花名册社保费用计算改为读取社保模块中各险种的独立基数和比例
2. 养老保险用养老基数、医疗保险用医疗基数,分别计算后汇总
3. 确保两个模块的计算逻辑统一
---
### 问题20:合同附件PDF/Word不支持在线查看,且无法删除传错的附件
**模块**:花名册 - 劳动合同
**优先级**P0
**状态**:待修复
**现状描述**
劳务合同附件上传了 PDF 后不可以查看,显示没有插件;Word 也不支持在线查看,只有图片格式可以查看。且附件上传之后传错了无法删除,没有删除按钮。
**代码核查结果**
- `ContractInfo.tsx:391-455` 附件预览弹窗实现:
- **图片**`<img>` 在线预览 ✅(`:432`
- **PDF**`<embed>` 在线预览 ✅(`:434`)——已支持,非完全缺失
- **Word/其他**:显示"此文件格式不支持在线预览",提供下载 ❌(`:436-449`
- 附件上传支持格式:`.pdf, .jpg, .jpeg, .png, .heic, .gif, .bmp, .webp, .doc, .docx, .xls, .xlsx, .tiff, .tif``:36,106`
- **新建合同时**的附件可删除(`:258`)✅
- **已保存合同的附件无删除按钮**——只有下载按钮(`:335-358`)和补充上传按钮(`:363`)❌
- 附件以 base64 data URL 存储在 `attachmentUrl` 字段中,预览时转为 blob URL
**涉及文件**
- `frontend/src/pages/roster/ContractInfo.tsx:16-70` 附件上传逻辑
- `frontend/src/pages/roster/ContractInfo.tsx:258` 新建时删除附件按钮
- `frontend/src/pages/roster/ContractInfo.tsx:310-370` 已保存合同附件展示(无删除)
- `frontend/src/pages/roster/ContractInfo.tsx:391-455` 附件预览弹窗
**优化方案**
1. Word 预览:使用 `mammoth.js` 转换为 HTML 在线预览,或提示下载查看
2. 已保存合同的附件增加删除按钮,删除时二次确认
3. 后端增加附件删除接口,更新 `attachmentUrl` 字段
---
### 问题21:用工办理与花名册添加员工功能重复
**模块**:花名册 / 用工办理
**优先级**P2
**状态**:待优化
**现状描述**
花名册可以添加员工,用工办理也可以录入员工,两个模块添加员工有什么区别不清楚。如果都可以添加没有必要,最好固定在一个模块。
**代码核查结果**
- `Roster.tsx``AddEmployeeModal``modals.tsx:486-767`)直接创建员工
- `WorkProcess.tsx:56-66``HIRE` 流程类型也创建员工,字段为 `name``department``idCardNumber` 等 text 输入
- `WorkProcess.tsx:67-70``ONBOARD` 流程使用 `employee-select` 选择已有员工
- 两个入口都调用 `createEmployee`,写入同一张表
**涉及文件**
- `frontend/src/pages/Roster.tsx:70-90` 花名册状态和模态框
- `frontend/src/pages/roster/modals.tsx:486-767` AddEmployeeModal
- `frontend/src/pages/WorkProcess.tsx:56-70` HIRE/ONBOARD 流程定义
- `backend/src/routes/employee.routes.ts:99-126` 创建员工路由
- `backend/src/services/contract.service.ts:193-272` createEmployee
**优化方案**
1. 统一员工添加入口为「用工办理 → 入职办理」,包含完整入职流程
2. 花名册保留「查看」和「编辑」功能,移除独立添加入口
3. 或在花名册添加员工时引导跳转到用工办理的入职流程
---
### 问题22:用工办理录入中途切换窗口丢失已填信息
**模块**:用工办理
**优先级**P1
**状态**:待修复
**现状描述**
在用工办理里录入员工,录到身份证号处,点开别的文件想粘贴一下,再回去,刚才录入的页面就退出了,需要重新打开重新录前面的信息。
**代码核查结果**
- `frontend/src/components/ui/Modal.tsx:36` 遮罩层 `onClick={onClose}`——**点击遮罩层会关闭弹窗**
-`closeOnOverlayClick={false}` 配置选项
- 表单数据未持久化到 `sessionStorage`
- `AddEmployeeModal``modals.tsx:641`)使用了 `useUnsavedChanges(isDirty)` 但仅提示,不阻止关闭
- `WorkProcess.tsx:204-205` 录入弹窗也使用 `div` + `onClick={onClose}` 模式
**涉及文件**
- `frontend/src/components/ui/Modal.tsx:33-56` Modal 组件(遮罩层 onClick={onClose}
- `frontend/src/pages/roster/modals.tsx:641-643` AddEmployeeModal useUnsavedChanges
- `frontend/src/pages/WorkProcess.tsx:204-205` 录入弹窗
**优化方案**
1. 弹窗设置为 `closeOnOverlayClick={false}`,禁止点击遮罩层关闭
2. 表单数据持久化到 `sessionStorage`,重新打开时恢复
3. 关闭前增加「确认关闭?未保存的数据将丢失」提示
---
### 问题23:用工办理未按身份证号查重
**模块**:用工办理
**优先级**P1
**状态**:待修复
**现状描述**
在花名册录入一个人,在用工办理里录入了一个人但没录入身份证号,不显示重复,不知道是否用身份证查重。
**代码核查结果**
- `createEmployee``contract.service.ts:193-272`**无查重逻辑**——直接创建
- 数据库依赖 `idCardHash` 唯一约束,重复时抛出 P2002 错误
- `errorHandler.ts:27-31` P2002 错误返回通用"数据已存在,请勿重复操作"消息
- 前端 `WorkProcess.tsx``HIRE` 流程类型使用 `text` 类型字段(`name``department` 等),**非 `employee-select`**
- `INCOME_CERT``LEAVING_CERT` 也使用 `text` 类型手动输入员工信息(`:107-114, :129-134`
- `import.routes.ts:330-333` 导入时有身份证号查重,返回字段级错误信息
**涉及文件**
- `backend/src/services/contract.service.ts:193-272` createEmployee(无查重)
- `backend/src/middleware/errorHandler.ts:27-31` P2002 错误处理
- `frontend/src/pages/WorkProcess.tsx:56-66` HIRE 流程字段定义
- `frontend/src/pages/WorkProcess.tsx:107-114,129-134` 证明开具字段(手动输入)
- `backend/src/routes/import.routes.ts:330-333` 导入查重(有字段级错误)
**优化方案**
1. 用工办理录入时根据姓名+手机号或身份证号查重
2. 身份证号为空时用姓名+手机号组合查重
3. 发现重复时提示「该员工已存在,是否查看/跳转」
---
## 九、证明开具模块
### 问题24:收入证明等应支持员工下拉选择,直接拉取数据
**模块**:用工办理 - 证明开具
**优先级**P1
**状态**:待优化
**现状描述**
开具收入证明或其他证明时,需要手动粘贴员工信息,应该有员工下拉选项直接拉取数据,避免开具非本公司员工的证明。
**代码核查结果**
- `INCOME_CERT``WorkProcess.tsx:107-114`)字段为手动输入:`employeeName`text)、`idCardNumber`text)、`position`text)、`monthlyIncome`text
- `LEAVING_CERT``:129-134`)同样为手动输入
- **未使用 `employee-select` 类型**,不关联花名册
- **但批量开具证明弹窗(`:537-590`)已有员工多选列表**——单条开具时却无下拉选择
- `WorkProcess.tsx:746-809``EmployeeSelect` 组件实现,支持搜索和选择员工
- `WorkProcess.tsx:298-306` 批量提交时从员工数据自动填充 `employeeName``idCardNumber``position`
**涉及文件**
- `frontend/src/pages/WorkProcess.tsx:107-114` INCOME_CERT 字段定义(手动输入)
- `frontend/src/pages/WorkProcess.tsx:129-134` LEAVING_CERT 字段定义(手动输入)
- `frontend/src/pages/WorkProcess.tsx:537-590` 批量开具证明弹窗(有员工选择)
- `frontend/src/pages/WorkProcess.tsx:746-809` EmployeeSelect 组件
- `frontend/src/pages/WorkProcess.tsx:298-306` 批量提交自动填充字段
**优化方案**
1. 证明开具表单增加员工下拉选择器,支持姓名/手机号搜索
2. 选择员工后自动填充身份证号、入职日期、职务、月收入等字段
3. 只允许选择本公司在职员工
---
## 十、培训记录模块
### 问题25:培训记录只能选择单个员工,不支持批量/按部门
**模块**:培训记录
**优先级**P1
**状态**:待优化
**现状描述**
添加培训记录只能选择一个员工,但实际培训可能是好几个员工一起,也可能是一个部门甚至整个公司。
**代码核查结果**
- `TrainingRecords.tsx:211-220` 员工选择为 `<Select>` 单选下拉框,`employees.map` 渲染选项
- `AttendanceOvertimeInfo.tsx:79-81` 中的培训记录新增也为单选
- **不支持多选或按部门批量选择**
- 表单字段:`employeeId``trainingDate``topic``content``trainer``duration``remark`
**涉及文件**
- `frontend/src/pages/roster/TrainingRecords.tsx:200-267` 培训记录表单(单选员工)
- `frontend/src/pages/roster/AttendanceOvertimeInfo.tsx:10,79-81` 考勤/培训合并组件
- `backend/src/routes/employee.routes.ts` 培训记录接口
**优化方案**
1. 员工选择改为多选模式,支持按部门筛选勾选
2. 增加「按部门添加」和「全公司添加」快捷选项
3. 批量创建培训记录,每人选一条,共享培训主题/日期/讲师等信息
---
## 十一、绩效考核模块
### 问题26:绩效考核模块过于片面,应支持导入公司自定义考核表
**模块**:绩效考核
**优先级**P2
**状态**:待优化
**现状描述**
绩效考核模块只有简单的得分/等级/评语,每个公司考核类别、评分等差别比较大,现有功能几乎没法用。应支持导入本公司绩效考核表,再进行个人绩效考核统计。
**代码核查结果**
- `PerformanceInfo.tsx:14` 表单仅包含:`period``periodType`(月度/季度/年度)、`score``grade`A/B/C/D)、`result`(优秀/合格/需改进/不胜任)、`summary``improvementPlan``reviewer``employeeAck`
- 得分自动计算等级和结果(`:29-39`
- **无自定义考核维度、权重、指标**
- 不支持导入 Excel 考核表
**涉及文件**
- `frontend/src/pages/roster/PerformanceInfo.tsx:1-118` 绩效考核完整组件
- `backend/src/routes/employee.routes.ts` 绩效记录接口
- `backend/prisma/schema.prisma` PerformanceRecord 模型
**优化方案**
1. 增加「绩效模板」管理,支持定义考核维度、权重、评分标准
2. 支持导入 Excel 考核表作为模板
3. 绩效考核时按模板填写各维度得分,系统按权重计算总分
4. 保留现有简单模式作为默认,自定义模板作为高级功能
---
## 十二、考勤导入流程
### 问题27:考勤模板 Sheet 过多,导入后无法确认数据
**模块**:考勤管理
**优先级**P0
**状态**:待优化
**现状描述**
考勤管理下载模板时模板包含太多无关 Sheet,需要都删除后再导入。而且导入后提示导入成功,但找不到从哪里确认数据。
**代码核查结果**
- 与问题7相关,`import.routes.ts:494-692` 模板包含多个无关 Sheet
- `Attendance.tsx:525-610` 导入弹窗显示导入结果(成功数、跳过数、错误),**但无跳转到考勤确认页面的链接**
- 导入成功后仅 toast 提示,无自动跳转
**涉及文件**
- `backend/src/routes/import.routes.ts:494-692` 模板下载
- `frontend/src/pages/Attendance.tsx:525-610` 前端导入弹窗
- `frontend/src/pages/attendance/AttendanceConfirm.tsx` 考勤确认页面
**优化方案**
1. 模板精简为单个考勤 Sheet(与问题7统一处理)
2. 导入成功后 toast 提示中增加「点击查看」跳转链接
3. 导入成功后自动跳转到考勤确认页面
---
## 十三、加班费计算
### 问题28:加班费计算需重复导入考勤数据
**模块**:薪税管理 / 考勤管理
**优先级**P1
**状态**:待优化
**现状描述**
加班费计算跟考勤不关联,到加班费计算时还需要再导入一遍考勤。
**问题分析**
- 与问题9相同,加班费计算模块独立于考勤管理
- 考勤管理中已确认的加班数据未传递到薪税计算
**涉及文件**
- `frontend/src/pages/money/` 加班费相关
- `backend/src/routes/payroll2.routes.ts`
- `backend/src/routes/import.routes.ts` 考勤导入(含加班记录 Sheet
- `backend/src/routes/attendance.routes.ts` 考勤数据查询
**优化方案**
1. 与问题9统一处理:加班费从考勤管理读取已确认的加班记录
2. 薪税批次创建时自动拉取当月加班数据
---
## 优先级汇总
| 优先级 | 编号 | 问题 |
|--------|------|------|
| P0 | 1 | 福利方案无法添加人员 | 功能已实现,pageSize 200 条限制待优化 |
| P0 | 3 | 离职证明下载乱码 | 待修复 |
| P0 | 7 | 考勤导入模板无关Sheet+合并 | 确认:模板含员工信息/劳动合同等无关Sheet |
| P0 | 8 | 补卡无法修改状态+时间显示异常 | 后端支持,需确认前端弹窗状态限制 |
| P0 | 20 | 合同附件PDF/Word不支持查看+无法删除 | PDF已支持预览,Word不支持,已保存附件无法删除 |
| P0 | 27 | 考勤模板Sheet过多+导入后无法确认 | 确认:无导入后跳转引导 |
| P1 | 2 | 每页条数选择无反应 | 需验证usePageSize事件触发 |
| P1 | 4 | 离职导出缺少筛选条件 | 确认:导出未传筛选参数 |
| P1 | 5 | 已提交离职无法撤回+已撤回无法删除 | 后端有revoke接口,前端UI未暴露 |
| P1 | 9 | 加班费与考勤不关联 | 确认:独立模块 |
| P1 | 10 | 加班汇总不显示手动添加的加班 | 确认:数据源未关联 |
| P1 | 11 | 证据链验证无法定位异常 | 确认:仅显示汇总数字 |
| P1 | 12 | 规章制度签收缺少催办和明细 | 确认:无催办按钮,未签收人员未列出 |
| P1 | 13 | 文本模板不支持导入文档 | 确认:仅textarea输入 |
| P1 | 14 | 社保基数选择参保地后未封上下限 | 确认:未查询城市上下限 |
| P1 | 16 | 校验失败未指明具体字段 | 确认:仅显示通用错误 |
| P1 | 17 | 员工详情薪税入口意义不明 | 需进一步确认 |
| P1 | 18 | 录入时缺少职务字段 | **确认:前后端均缺失position字段** |
| P1 | 19 | 花名册社保计算与社保模块不一致 | 确认:使用统一基数 |
| P1 | 22 | 用工办理录入中途切换窗口丢失数据 | 确认:遮罩层点击关闭,无持久化 |
| P1 | 23 | 用工办理未按身份证号查重 | 确认:无查重逻辑,仅依赖DB唯一约束 |
| P1 | 24 | 证明开具不支持员工下拉选择 | 确认:手动输入,批量开具有选择器 |
| P1 | 25 | 培训记录不支持批量选择员工 | 确认:单选下拉框 |
| P1 | 28 | 加班费需重复导入考勤 | 与问题9相同 |
| P2 | 6 | 用工办理与离职管理功能重复 | 确认 |
| P2 | 15 | 社保基数修改不能直接覆盖 | 确认:value回退问题 |
| P2 | 21 | 花名册与用工办理添加员工重复 | 确认 |
| P2 | 26 | 绩效考核模块过于片面 | 确认:固定字段,无自定义 |
+131
View File
@@ -16,6 +16,7 @@
"file-saver": "^2.0.5",
"jspdf": "^4.2.1",
"lucide-react": "^0.428.0",
"mammoth": "^1.12.0",
"qrcode.react": "^4.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -1590,6 +1591,15 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.13",
"resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz",
@@ -1639,6 +1649,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
@@ -1714,6 +1733,26 @@
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.1",
"resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz",
@@ -1740,6 +1779,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bluebird": {
"version": "3.4.7",
"resolved": "https://registry.npmmirror.com/bluebird/-/bluebird-3.4.7.tgz",
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
"license": "MIT"
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz",
@@ -2263,6 +2308,12 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dingbat-to-unicode": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
"license": "BSD-2-Clause"
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmmirror.com/dlv/-/dlv-1.1.3.tgz",
@@ -2330,6 +2381,15 @@
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/duck": {
"version": "0.1.12",
"resolved": "https://registry.npmmirror.com/duck/-/duck-0.1.12.tgz",
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
"license": "BSD",
"dependencies": {
"underscore": "^1.13.1"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3257,6 +3317,17 @@
"loose-envify": "cli.js"
}
},
"node_modules/lop": {
"version": "0.4.2",
"resolved": "https://registry.npmmirror.com/lop/-/lop-0.4.2.tgz",
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
"license": "BSD-2-Clause",
"dependencies": {
"duck": "^0.1.12",
"option": "~0.2.1",
"underscore": "^1.13.1"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -3276,6 +3347,30 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/mammoth": {
"version": "1.12.0",
"resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.0.tgz",
"integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==",
"license": "BSD-2-Clause",
"dependencies": {
"@xmldom/xmldom": "^0.8.6",
"argparse": "~1.0.3",
"base64-js": "^1.5.1",
"bluebird": "~3.4.0",
"dingbat-to-unicode": "^1.0.1",
"jszip": "^3.7.1",
"lop": "^0.4.2",
"path-is-absolute": "^1.0.0",
"underscore": "^1.13.1",
"xmlbuilder": "^10.0.0"
},
"bin": {
"mammoth": "bin/mammoth"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/markdown-table": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/markdown-table/-/markdown-table-3.0.4.tgz",
@@ -4256,6 +4351,12 @@
"node": ">= 6"
}
},
"node_modules/option": {
"version": "0.2.4",
"resolved": "https://registry.npmmirror.com/option/-/option-0.2.4.tgz",
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
"license": "BSD-2-Clause"
},
"node_modules/pako": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/pako/-/pako-2.2.0.tgz",
@@ -4309,6 +4410,15 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz",
@@ -5090,6 +5200,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz",
@@ -5378,6 +5494,12 @@
"node": ">=14.17"
}
},
"node_modules/underscore": {
"version": "1.13.8",
"resolved": "https://registry.npmmirror.com/underscore/-/underscore-1.13.8.tgz",
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-8.3.0.tgz",
@@ -5719,6 +5841,15 @@
"xml-js": "bin/cli.js"
}
},
"node_modules/xmlbuilder": {
"version": "10.1.1",
"resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
+1
View File
@@ -17,6 +17,7 @@
"file-saver": "^2.0.5",
"jspdf": "^4.2.1",
"lucide-react": "^0.428.0",
"mammoth": "^1.12.0",
"qrcode.react": "^4.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+3 -2
View File
@@ -9,9 +9,10 @@ interface ModalProps {
children: ReactNode
className?: string
size?: 'sm' | 'md' | 'lg' | 'xl'
closeOnOverlayClick?: boolean
}
export default function Modal({ open, onClose, title, children, className, size = 'md' }: ModalProps) {
export default function Modal({ open, onClose, title, children, className, size = 'md', closeOnOverlayClick = true }: ModalProps) {
const [show, setShow] = useState(false)
useEffect(() => {
@@ -33,7 +34,7 @@ export default function Modal({ open, onClose, title, children, className, size
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className={clsx('fixed inset-0 bg-black/40 transition-opacity duration-200', show ? 'opacity-100' : 'opacity-0')}
onClick={onClose}
onClick={closeOnOverlayClick ? onClose : undefined}
/>
<div
className={clsx(
+2 -1
View File
@@ -1,5 +1,6 @@
import clsx from 'clsx'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { setPageSize } from '../../lib/pageSize'
interface PaginationProps {
page: number // 当前页(1-based
@@ -45,7 +46,7 @@ export default function Pagination({
<select
className="border rounded px-1.5 py-0.5 text-sm text-gray-600 focus:outline-none focus:border-primary"
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
onChange={(e) => { setPageSize(Number(e.target.value)); onPageSizeChange?.(Number(e.target.value)) }}
>
{pageSizeOptions.map((n) => (
<option key={n} value={n}>{n} /</option>
+34 -1
View File
@@ -66,6 +66,9 @@ export const employeeApi = {
/** 创建员工 */
create: (data: Record<string, unknown>) =>
post('/employees', data),
/** 身份证查重 */
checkIdCard: (idCard: string) =>
get('/employees/check-id-card', { params: { idCard } }).then(unwrap<{ exists: boolean; employee?: any }>()),
/** 更新员工 */
update: (id: string, data: Record<string, unknown>) =>
put(`/employees/${id}`, data),
@@ -120,11 +123,26 @@ export const rosterApi = {
expiringContracts: () =>
get('/roster/contracts/expiring').then(unwrap<any[]>()),
/** 培训记录列表(全员) */
trainingList: (params: { page?: number; pageSize?: number; keyword?: string }) =>
trainingList: (params: { page?: number; pageSize?: number; keyword?: string; ackStatus?: string }) =>
get('/roster/training/list', { params }).then(unwrap<any>()),
/** 培训记录催办 */
trainingRemind: (recordId: string) =>
post(`/roster/training/remind/${recordId}`).then(unwrap<any>()),
/** 绩效记录列表(全员) */
performanceList: (params: { page?: number; pageSize?: number; keyword?: string }) =>
get('/roster/performance/list', { params }).then(unwrap<any>()),
/** 绩效模板列表 */
performanceTemplates: () =>
get('/roster/performance/templates').then(unwrap<any[]>()),
/** 创建绩效模板 */
createPerformanceTemplate: (data: any) =>
post('/roster/performance/templates', data).then(unwrap<any>()),
/** 更新绩效模板 */
updatePerformanceTemplate: (id: string, data: any) =>
put(`/roster/performance/templates/${id}`, data).then(unwrap<any>()),
/** 删除绩效模板 */
deletePerformanceTemplate: (id: string) =>
del(`/roster/performance/templates/${id}`).then(unwrap<any>()),
/** 违纪记录列表(全员) */
disciplinaryList: (params: { page?: number; pageSize?: number; keyword?: string }) =>
get('/roster/disciplinary/list', { params }).then(unwrap<any>()),
@@ -447,6 +465,9 @@ export const payrollApi = {
/** 批量导入加班工时 */
batchImportOvertime: (data: Record<string, unknown>[]) =>
post('/payroll/overtime/batch', data),
/** 从考勤记录同步加班工时 */
syncOvertimeFromAttendance: (month: string) =>
post('/payroll/overtime/sync-from-attendance', { month }).then(unwrap<any>()),
/** 导入加班费到批次 */
importOvertimeToBatch: (batchId: string) =>
post(`/payroll/overtime/import-to-batch/${batchId}`).then(unwrap<any>()),
@@ -655,6 +676,9 @@ export const terminationApi = {
/** 撤销 */
cancel: (draftId: string) =>
post(`/termination/draft/${draftId}/cancel`),
/** 删除草稿(仅 DRAFT 和 CANCELLED 状态) */
deleteDraft: (draftId: string) =>
del(`/termination/draft/${draftId}`),
/** 撤回离职记录 */
revoke: (recordId: string) =>
del(`/termination/${recordId}/revoke`),
@@ -693,6 +717,9 @@ export const policiesApi = {
/** 阅读签收统计 */
readStats: (id: string) =>
get(`/policies/${id}/read-stats`).then(unwrap<any>()),
/** 催办未签收员工 */
remind: (id: string, employeeIds?: string[]) =>
post(`/policies/${id}/remind`, { employeeIds }).then(unwrap<any>()),
}
// ========== 证据链相关 ==========
@@ -704,6 +731,12 @@ export const evidenceApi = {
/** 全量验证 */
verifyAll: () =>
get('/evidence/verify-all').then(unwrap<any>()),
/** 按员工获取证据链记录 */
byEmployee: (employeeId: string) =>
get(`/evidence/employee/${employeeId}`).then(unwrap<any[]>()),
/** 验证单条证据链 */
verify: (id: string) =>
get(`/evidence/verify/${id}`).then(unwrap<any>()),
}
// ========== 审计日志 ==========
+19
View File
@@ -0,0 +1,19 @@
import { toast } from 'sonner'
/**
* 从 axios 错误中提取并显示错误信息,支持 Zod 校验失败时展示具体字段
*/
export function toastError(err: any, fallback = '操作失败') {
const error = err?.response?.data?.error
if (!error) {
toast.error(fallback)
return
}
// 如果有 details(Zod 校验失败),展示具体字段
if (error.details && Array.isArray(error.details) && error.details.length > 0) {
const fields = error.details.map((d: any) => `${d.path || '字段'}: ${d.message}`).join('')
toast.error(`${error.message}${fields}`)
return
}
toast.error(error.message || fallback)
}
+21 -9
View File
@@ -83,7 +83,7 @@ export default function Attendance() {
})}
</div>
{activeTab === 'confirm' && <ConfirmTab />}
{activeTab === 'confirm' && <ConfirmTab onGoToTab={setActiveTab} />}
{activeTab === 'shifts' && <ShiftsTab />}
{activeTab === 'schedule' && <ScheduleTab />}
{activeTab === 'daily' && <DailyTab />}
@@ -94,7 +94,7 @@ export default function Attendance() {
}
// ========== 考勤确认 Tab ==========
function ConfirmTab() {
function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
@@ -538,14 +538,14 @@ function ConfirmTab() {
try {
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/import/template`, {
const res = await fetch(`${baseURL}/import/monthly-template`, {
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 = '员工导入模板.xlsx'
a.download = '考勤月度导入模板.xlsx'
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('下载模板失败') }
@@ -555,7 +555,7 @@ function ConfirmTab() {
</div>
<div className="text-xs text-gray-500 bg-blue-50/50 rounded-md p-2">
Sheet
Sheet 0
</div>
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
@@ -570,6 +570,10 @@ function ConfirmTab() {
<div className="font-medium"></div>
{importResult.attendance > 0 && <div>{importResult.attendance} </div>}
{importResult.overtime > 0 && <div>{importResult.overtime} </div>}
{importResult.discipline > 0 && <div>{importResult.discipline} </div>}
{importResult.salaryChanges > 0 && <div>{importResult.salaryChanges} </div>}
{importResult.socialInsChanges > 0 && <div>{importResult.socialInsChanges} </div>}
{importResult.housingFundChanges > 0 && <div>{importResult.housingFundChanges} </div>}
{importResult.employees > 0 && <div>{importResult.employees} </div>}
{importResult.contracts > 0 && <div>{importResult.contracts} </div>}
{importResult.skipped > 0 && <div className="text-amber-600"> {importResult.skipped} </div>}
@@ -579,6 +583,12 @@ function ConfirmTab() {
{importResult.errors.length > 5 && <div className="text-amber-600">... {importResult.errors.length - 5} </div>}
</div>
)}
<button
className="mt-1 text-primary hover:underline font-medium"
onClick={() => { setShowImport(false); setImportFile(null); setImportResult(null); onGoToTab?.('confirm') }}
>
</button>
</div>
)}
@@ -1094,8 +1104,8 @@ function DailyTab() {
<td className="px-4 py-3 font-medium">{emp.name}</td>
<td className="px-4 py-3 text-gray-500">{emp.department}</td>
<td className="px-4 py-3 text-xs text-gray-500">{emp.shift ? `${emp.shift.name}` : '—'}</td>
<td className="px-4 py-3 text-xs font-mono">{emp.checkInTime || '—'}</td>
<td className="px-4 py-3 text-xs font-mono">{emp.checkOutTime || '—'}</td>
<td className="px-4 py-3 text-xs font-mono">{emp.checkInTime ? (() => { const d = new Date(emp.checkInTime); return `${String(d.getUTCHours()).padStart(2,'0')}:${String(d.getUTCMinutes()).padStart(2,'0')}`; })() : '—'}</td>
<td className="px-4 py-3 text-xs font-mono">{emp.checkOutTime ? (() => { const d = new Date(emp.checkOutTime); return `${String(d.getUTCHours()).padStart(2,'0')}:${String(d.getUTCMinutes()).padStart(2,'0')}`; })() : '—'}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${statusColors[emp.status] || 'bg-gray-100 text-gray-500'}`}>
{ATTENDANCE_STATUS[emp.status] || emp.status}
@@ -1107,9 +1117,10 @@ function DailyTab() {
className="text-xs text-primary hover:underline"
onClick={() => {
setEditEmp(emp)
const fmtTime = (t: string) => { if (!t) return ''; const d = new Date(t); return `${String(d.getUTCHours()).padStart(2,'0')}:${String(d.getUTCMinutes()).padStart(2,'0')}` }
setEditForm({
checkInTime: emp.checkInTime || '',
checkOutTime: emp.checkOutTime || '',
checkInTime: fmtTime(emp.checkInTime),
checkOutTime: fmtTime(emp.checkOutTime),
status: emp.status || 'NORMAL',
remark: '',
})
@@ -1156,6 +1167,7 @@ function DailyTab() {
<option value="ABSENT"></option>
<option value="LEAVE"></option>
<option value="BUSINESS_TRIP"></option>
<option value="UNREGISTERED"></option>
</Select>
</div>
<div className="col-span-2">
+9 -1
View File
@@ -61,6 +61,7 @@ export default function Contracts() {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
localStorage.removeItem('add-employee-draft')
setShowAddModal(false)
},
})
@@ -219,6 +220,7 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
const [form, setForm] = useState({
name: '',
department: '',
position: '',
hireDate: '',
monthlySalary: '',
gender: '男' as '男' | '女',
@@ -239,6 +241,7 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
const data: any = {
name: form.name,
department: form.department,
position: form.position || undefined,
hireDate: new Date(form.hireDate).toISOString(),
monthlySalary: form.monthlySalary,
gender: form.gender,
@@ -282,15 +285,20 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>/</Label>
<Input value={form.position} onChange={(e) => setForm({ ...form, position: e.target.value })} placeholder="如:前端工程师" />
</div>
<div>
<Label> *</Label>
<Input type="date" value={form.hireDate} onChange={(e) => setForm({ ...form, hireDate: e.target.value })} />
</div>
</div>
<div>
<Label> *</Label>
<Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
+7 -7
View File
@@ -3,7 +3,7 @@ import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { Gift, Plus, Settings as SettingsIcon, X, Users } from 'lucide-react'
import { benefitApi, rosterApi } from '../lib/api-services'
import { benefitApi, employeeApi } from '../lib/api-services'
import PageGuide from '../components/ui/PageGuide'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
@@ -72,10 +72,10 @@ export default function EmployeeBenefits() {
enabled: tab === 'summary',
})
const { data: rosterData } = useQuery<any>({
queryKey: ['roster-for-benefit', ''],
const { data: rosterData } = useQuery<any[]>({
queryKey: ['employees-for-benefit'],
queryFn: async () => {
return await rosterApi.list({ search: '', page: 1, pageSize: 200 } as any) as any
return await employeeApi.allLite({ status: 'ACTIVE' })
},
enabled: showEnrollModal,
})
@@ -407,10 +407,10 @@ export default function EmployeeBenefits() {
<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?.items?.filter((e: any) => e.status === 'ACTIVE').length || 0) && enrollEmployeeIds.length > 0}
<input type="checkbox" checked={enrollEmployeeIds.length === (rosterData?.length || 0) && enrollEmployeeIds.length > 0}
onChange={(e) => {
if (e.target.checked) {
setEnrollEmployeeIds(rosterData?.items?.filter((emp: any) => emp.status === 'ACTIVE').map((emp: any) => emp.id) || [])
setEnrollEmployeeIds(rosterData?.map((emp: any) => emp.id) || [])
} else {
setEnrollEmployeeIds([])
}
@@ -422,7 +422,7 @@ export default function EmployeeBenefits() {
</tr>
</thead>
<tbody>
{rosterData?.items?.filter((e: any) => e.status === 'ACTIVE').map((emp: any) => (
{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)}
+13
View File
@@ -71,6 +71,19 @@ export default function Evidence() {
: `${verifyResult?.valid || 0} 条通过,${verifyResult?.invalid || 0} 条异常,请检查`}
</span>
</div>
{verifyResult?.invalidItems?.length > 0 && (
<div className="mt-3 space-y-1.5">
{verifyResult.invalidItems.map((item: any) => (
<div key={item.id} className="flex items-center justify-between px-3 py-2 rounded bg-red-50 border border-red-200 text-sm">
<div className="flex items-center gap-2">
<XCircle className="w-4 h-4 text-red-500 shrink-0" />
<span className="text-red-700">{item.description}</span>
</div>
<span className="text-xs text-gray-400">{new Date(item.createdAt).toLocaleString('zh-CN')}</span>
</div>
))}
</div>
)}
</Card>
)}
+5 -2
View File
@@ -1,4 +1,5 @@
import { useState, lazy, Suspense } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Layers, Wallet, LayoutTemplate, Clock, Receipt, Loader2 } from 'lucide-react'
const BatchManager = lazy(() => import('./money/BatchTab').then(m => ({ default: m.BatchManager })))
@@ -9,7 +10,9 @@ const PayslipManager = lazy(() => import('./money/PayslipTab').then(m => ({ defa
type Tab = 'batch' | 'template' | 'overtime' | 'payslip'
export default function Money() {
const [tab, setTab] = useState<Tab>('batch')
const [searchParams] = useSearchParams()
const initialEmployeeId = searchParams.get('employeeId') || ''
const [tab, setTab] = useState<Tab>(initialEmployeeId ? 'payslip' : 'batch')
const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [
{ key: 'batch', label: '发薪批次', icon: <Layers className="w-4 h-4" /> },
@@ -47,7 +50,7 @@ export default function Money() {
{tab === 'batch' && <BatchManager />}
{tab === 'template' && <TemplateManager />}
{tab === 'overtime' && <OvertimeCalculator />}
{tab === 'payslip' && <PayslipManager />}
{tab === 'payslip' && <PayslipManager filterEmployeeId={initialEmployeeId} />}
</Suspense>
</div>
)
+33 -3
View File
@@ -2,7 +2,7 @@ import { useState } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { FileText, Plus, ChevronRight, CheckCircle, Clock, X } from 'lucide-react'
import { FileText, Plus, ChevronRight, CheckCircle, Clock, X, Bell } from 'lucide-react'
import { policiesApi } from '../lib/api-services'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
@@ -247,6 +247,8 @@ function CreatePolicyModal({ onClose, onSuccess }: { onClose: () => void; onSucc
* 阅读签收统计组件
*/
function ReadStats({ policyId }: { policyId: string }) {
const queryClient = useQueryClient()
const [showUnread, setShowUnread] = useState(false)
const { data, isLoading } = useQuery<any>({
queryKey: ['policy-read-stats', policyId],
queryFn: async () => {
@@ -254,6 +256,17 @@ function ReadStats({ policyId }: { policyId: string }) {
},
})
const remindMutation = useMutation({
mutationFn: async (employeeIds?: string[]) => {
return await policiesApi.remind(policyId, employeeIds)
},
onSuccess: (res: any) => {
toast.success(`已催办 ${res?.reminded || 0} 名未签收员工`)
queryClient.invalidateQueries({ queryKey: ['policy-read-stats', policyId] })
},
onError: () => toast.error('催办失败'),
})
if (isLoading) return <div className="text-xs text-gray-400 mt-3">...</div>
if (!data) return null
@@ -271,8 +284,25 @@ function ReadStats({ policyId }: { policyId: string }) {
</span>
</div>
{data.unreadCount > 0 && (
<div className="text-xs text-amber-600 mb-2">
{data.unreadCount}
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-amber-600">{data.unreadCount} </span>
<button onClick={() => setShowUnread(!showUnread)} className="text-xs text-primary hover:underline">
{showUnread ? '收起' : '查看明细'}
</button>
<Button size="sm" variant="secondary" className="!h-6 !px-2 !text-xs" onClick={() => remindMutation.mutate(undefined)} disabled={remindMutation.isPending}>
<Bell className="w-3 h-3 mr-1" />
</Button>
</div>
)}
{showUnread && data.unreadEmployees && data.unreadEmployees.length > 0 && (
<div className="max-h-40 overflow-y-auto space-y-1 mb-2">
{data.unreadEmployees.map((r: any) => (
<div key={r.employeeId} className="flex items-center justify-between px-2 py-1 rounded bg-amber-50 text-xs">
<span className="text-gray-700">{r.employeeName}</span>
<span className="text-gray-400">{r.department}</span>
<span className="text-amber-600"></span>
</div>
))}
</div>
)}
{data.records && data.records.length > 0 && (
+34 -1
View File
@@ -2,6 +2,7 @@ import { useState, useMemo, useEffect } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { usePageSize } from '../hooks/usePageSize'
import { toast } from 'sonner'
import { toastError } from '../lib/errorToast'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { Users, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, Upload, Wallet, Download, Phone, MapPin, Search, Settings2 } from 'lucide-react'
@@ -33,6 +34,8 @@ const ROSTER_COLUMNS = [
{ key: 'contractStatus', label: '合同状态' },
{ key: 'contractExpiry', label: '合同到期' },
{ key: 'socialStatus', label: '社保状态' },
{ key: 'socialInsBase', label: '社保基数' },
{ key: 'socialInsAmount', label: '社保缴费' },
{ key: 'records', label: '记录' },
] as const
@@ -127,8 +130,13 @@ export default function Roster() {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
localStorage.removeItem('add-employee-draft')
setShowAddModal(false)
toast.success('员工已添加', {
action: { label: '前往用工办理', onClick: () => navigate('/work-process') },
})
},
onError: (err: any) => toastError(err, '创建失败'),
})
const resignMutation = useMutation({
@@ -373,6 +381,9 @@ export default function Roster() {
<Button onClick={() => setShowAddModal(true)} className="h-9 shrink-0">
<Plus className="mr-1.5 h-4 w-4" />
</Button>
<Button variant="secondary" onClick={() => navigate('/work-process')} className="h-9 shrink-0" title="完整的入职办理流程">
<UserPlus className="mr-1.5 h-4 w-4" />
</Button>
<Button variant="secondary" onClick={() => setShowImportModal(true)} className="h-9 shrink-0">
<Upload className="mr-1.5 h-4 w-4" />
</Button>
@@ -504,6 +515,8 @@ export default function Roster() {
{colVisible('contractStatus') && <th className="px-4 py-3 text-left"></th>}
{colVisible('contractExpiry') && <th className="px-4 py-3 text-left"></th>}
{colVisible('socialStatus') && <th className="px-4 py-3 text-left"></th>}
{colVisible('socialInsBase') && <th className="px-4 py-3 text-right"></th>}
{colVisible('socialInsAmount') && <th className="px-4 py-3 text-right"></th>}
{colVisible('records') && <th className="px-4 py-3 text-center"></th>}
<th className="px-4 py-3 text-center"></th>
</tr>
@@ -636,6 +649,26 @@ export default function Roster() {
return <span className={`px-2 py-0.5 rounded text-xs ${c.style}`}>{c.label}</span>
})()}
</td>}
{colVisible('socialInsBase') && <td className="px-4 py-3 text-right text-xs">
{e.socialInsBase ? e.socialInsBase.toLocaleString() : <span className="text-gray-300"></span>}
</td>}
{colVisible('socialInsAmount') && <td className="px-4 py-3 text-right text-xs">
{(() => {
if (!e.socialInsCalc && !e.housingFundCalc) return <span className="text-gray-300"></span>
const socialEmp = e.socialInsCalc?.socialEmp || 0
const socialOrg = e.socialInsCalc?.socialOrg || 0
const housingEmp = e.housingFundCalc?.housingEmp || 0
const housingOrg = e.housingFundCalc?.housingOrg || 0
const totalEmp = socialEmp + housingEmp
const totalOrg = socialOrg + housingOrg
return (
<div className="flex flex-col">
<span>: {totalEmp.toFixed(2)}</span>
<span className="text-gray-400">: {totalOrg.toFixed(2)}</span>
</div>
)
})()}
</td>}
{colVisible('records') && <td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-1 flex-wrap">
{(() => {
@@ -761,7 +794,7 @@ export default function Roster() {
<div className="border-t border-gray-100 px-5 py-3">
<Pagination
page={pagination.page}
pageSize={pagination.pageSize}
pageSize={pageSize}
total={pagination.total}
onPageChange={(p) => setPage(p)}
onPageSizeChange={() => setPage(1)}
+29 -2
View File
@@ -1,13 +1,14 @@
import { useState } from 'react'
import { useState, useRef } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { FileText, Copy, X, Download, BookOpen, HelpCircle, Plus, Edit, Trash2, Building2 } from 'lucide-react'
import { FileText, Copy, X, Download, BookOpen, HelpCircle, Plus, Edit, Trash2, Building2, Upload } from 'lucide-react'
import { toast } from 'sonner'
import { templatesApi } from '../lib/api-services'
import { useAuthStore } from '../store/authStore'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import mammoth from 'mammoth'
import Modal from '../components/ui/Modal'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
@@ -303,6 +304,7 @@ function SystemTemplates() {
function EnterpriseTemplates() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const fileInputRef = useRef<HTMLInputElement>(null)
const [category, setCategory] = useState<string>('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
@@ -490,6 +492,31 @@ function EnterpriseTemplates() {
</div>
<div>
<Label></Label>
<div className="flex items-center gap-2 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 })
setForm({ ...form, content: result.value })
toast.success('文档导入成功')
} catch {
toast.error('文档解析失败,请确保为 .docx 格式')
}
e.target.value = ''
}}
/>
<span className="text-xs text-gray-400"> .docx HTML</span>
</div>
<textarea
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary text-sm min-h-[200px] font-mono"
value={form.content}
+90 -36
View File
@@ -205,6 +205,8 @@ export default function Termination() {
const [filterStatus, setFilterStatus] = useState('')
const [filterDepartment, setFilterDepartment] = useState('')
const [searchTerm, setSearchTerm] = useState('')
const [filterDateFrom, setFilterDateFrom] = useState('')
const [filterDateTo, setFilterDateTo] = useState('')
const draftPageSize = usePageSize()
const [draftPage, setDraftPage] = useState(1)
@@ -433,6 +435,16 @@ export default function Termination() {
onError: () => toast.error('撤销失败'),
})
// 删除草稿
const deleteDraftMutation = useMutation({
mutationFn: (id: string) => terminationApi.deleteDraft(id),
onSuccess: () => {
toast.success('草稿已删除')
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
},
onError: () => toast.error('删除失败'),
})
const { data: evidenceChain } = useQuery({
queryKey: ['evidence-chain', employeeId],
queryFn: async () => {
@@ -728,8 +740,23 @@ export default function Termination() {
<option value=""></option>
{departmentList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
</select>
{(searchTerm || filterStatus || filterDepartment) && (
<button onClick={() => { setSearchTerm(''); setFilterStatus(''); setFilterDepartment('') }} className="text-xs text-gray-500 hover:text-primary"></button>
<input
type="date"
value={filterDateFrom}
onChange={(e) => setFilterDateFrom(e.target.value)}
className="h-9 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
placeholder="开始日期"
/>
<span className="text-xs text-gray-400"></span>
<input
type="date"
value={filterDateTo}
onChange={(e) => setFilterDateTo(e.target.value)}
className="h-9 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
placeholder="结束日期"
/>
{(searchTerm || filterStatus || filterDepartment || filterDateFrom || filterDateTo) && (
<button onClick={() => { setSearchTerm(''); setFilterStatus(''); setFilterDepartment(''); setFilterDateFrom(''); setFilterDateTo('') }} className="text-xs text-gray-500 hover:text-primary"></button>
)}
<Button variant="secondary" size="sm" onClick={async () => {
try {
@@ -737,6 +764,8 @@ export default function Termination() {
if (searchTerm) params.set('search', searchTerm)
if (filterStatus) params.set('status', filterStatus)
if (filterDepartment) params.set('department', filterDepartment)
if (filterDateFrom) params.set('dateFrom', filterDateFrom)
if (filterDateTo) params.set('dateTo', filterDateTo)
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/export/terminations?${params}`, { headers: { Authorization: `Bearer ${token}` } })
@@ -866,6 +895,20 @@ export default function Termination() {
<Ban className="w-3.5 h-3.5" />
</button>
)}
{(item.status === 'DRAFT' || item.status === 'CANCELLED') && (
<button
onClick={async () => {
if (await confirm({ title: '删除草稿', message: '确定删除此草稿记录?删除后不可恢复。' })) {
deleteDraftMutation.mutate(item.id)
}
}}
className="p-1 text-gray-400 hover:text-danger"
aria-label="删除"
title="删除"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={() => handleViewDetail(item.id)}
className="p-1 text-gray-500 hover:text-primary"
@@ -1025,21 +1068,29 @@ export default function Termination() {
<Button
variant="secondary"
onClick={() => {
const doc = new jsPDF()
doc.setFontSize(18)
doc.text('解除/终止劳动合同证明书', 105, 25, { align: 'center' })
doc.setFontSize(11)
let y = 45
doc.text(`兹证明 ${draftDetail.employeeName}(身份证号:${draftDetail.idCardNumber || '—'}),`, 14, y); y += 8
doc.text(`原系我公司 ${draftDetail.department} 部门员工,`, 14, y); y += 8
doc.text(`${draftDetail.terminationDate}${REASONS.find(r => r.value === draftDetail.reason)?.label || draftDetail.reason} 原因,`, 14, y); y += 8
doc.text(`正式解除/终止劳动合同。`, 14, y); y += 8
doc.text(`经济补偿金已结清:¥${fmt(draftDetail.compensation)}`, 14, y); y += 8
doc.text(`社保截止月份:${draftDetail.socialInsEndMonth || '—'},公积金截止月份:${draftDetail.housingFundEndMonth || '—'}`, 14, y); y += 16
doc.text('特此证明。', 14, y); y += 24
doc.text('公司(盖章)', 140, y)
doc.text(new Date().toISOString().slice(0, 10), 140, y + 8)
doc.save(`离职证明-${draftDetail.employeeName}-${draftDetail.terminationDate}.pdf`)
const reasonLabel = REASONS.find(r => r.value === draftDetail.reason)?.label || draftDetail.reason
const html = `<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>
<style>
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: center; }
.title { font-size: 22pt; font-weight: bold; margin-bottom: 30pt; }
.body { text-align: justify; text-indent: 2em; margin: 0 20pt; }
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; }
</style></head>
<body>
<div class="title">解除/终止劳动合同证明书</div>
<div class="body">兹证明 ${draftDetail.employeeName}(身份证号:${draftDetail.idCardNumber || '—'}),原系我公司 ${draftDetail.department || '—'} 部门员工,于 ${draftDetail.terminationDate}${reasonLabel} 原因,正式解除/终止劳动合同。</div>
<div class="body">经济补偿金已结清:¥${fmt(draftDetail.compensation)}。社保截止月份:${draftDetail.socialInsEndMonth || '—'},公积金截止月份:${draftDetail.housingFundEndMonth || '—'}。</div>
<div class="body">特此证明。</div>
<div class="sign">公司(盖章)<br/>${new Date().toISOString().slice(0, 10)}</div>
</body></html>`
const blob = new Blob(['\ufeff' + html], { type: 'application/msword;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `离职证明-${draftDetail.employeeName}-${draftDetail.terminationDate}.doc`
a.click()
URL.revokeObjectURL(url)
}}
>
<Download className="w-4 h-4 mr-1" />
@@ -1048,25 +1099,28 @@ export default function Termination() {
<Button
variant="secondary"
onClick={() => {
const doc = new jsPDF()
doc.setFontSize(16)
doc.text('工作交接清单', 105, 25, { align: 'center' })
doc.setFontSize(11)
let y = 40
doc.text(`员工姓名:${draftDetail.employeeName}`, 14, y); y += 8
doc.text(`部门:${draftDetail.department || '—'}`, 14, y); y += 8
doc.text(`离职日期:${draftDetail.terminationDate}`, 14, y); y += 12
doc.setFontSize(10)
draftDetail.handoverItems.forEach((item: any, i: number) => {
if (y > 270) { doc.addPage(); y = 20 }
doc.text(`${item.done ? '[√]' : '[ ]'} ${item.label}${item.remark ? '' + item.remark + '' : ''}`, 14, y); y += 7
})
y += 16
doc.text('交接人签字:____________', 14, y)
doc.text('接收人签字:____________', 100, y)
y += 12
doc.text('日期:____________', 14, y)
doc.save(`交接清单-${draftDetail.employeeName}-${draftDetail.terminationDate}.pdf`)
const items = draftDetail.handoverItems.map((item: any) => `<div class="body">${item.done ? '[√]' : '[ ]'} ${item.label}${item.remark ? '' + item.remark + '' : ''}</div>`).join('')
const html = `<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>
<style>
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: center; }
.title { font-size: 18pt; font-weight: bold; margin-bottom: 20pt; }
.body { text-align: justify; text-indent: 2em; margin: 0 20pt; }
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; }
</style></head>
<body>
<div class="title">工作交接清单</div>
<div class="body">员工姓名:${draftDetail.employeeName}  部门:${draftDetail.department || '—'}  离职日期:${draftDetail.terminationDate}</div>
${items}
<div class="sign">交接人签字:____________<br/>接收人签字:____________<br/>日期:____________</div>
</body></html>`
const blob = new Blob(['\ufeff' + html], { type: 'application/msword;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `交接清单-${draftDetail.employeeName}-${draftDetail.terminationDate}.doc`
a.click()
URL.revokeObjectURL(url)
}}
>
<Download className="w-4 h-4 mr-1" />
+74 -32
View File
@@ -1,11 +1,13 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { usePageSize } from '../hooks/usePageSize'
import { useUnsavedChanges } from '../hooks/useUnsavedChanges'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { toastError } from '../lib/errorToast'
import {
UserPlus, LogIn, FileSignature, Edit, CheckCircle, RefreshCw,
Repeat, Pause, FileText, XCircle, UserX, FileMinus, Briefcase,
Repeat, Pause, FileText, Briefcase,
Loader2, ChevronRight, Trash2, Send, X, Eye, Search, Download, Users,
} from 'lucide-react'
import { workProcessApi, templatesApi, employeeApi } from '../lib/api-services'
@@ -21,7 +23,6 @@ const PROCESS_ICONS: Record<string, any> = {
HIRE: UserPlus, ONBOARD: LogIn, CUSTOM_CONTRACT: FileSignature,
INFO_SUBMIT: Edit, CONFIRM: CheckCircle, CHANGE: RefreshCw,
RENEW: Repeat, SUSPEND: Pause, INCOME_CERT: FileText,
TERMINATE: XCircle, RESCIND: UserX, LEAVING_CERT: FileMinus,
FLEXIBLE: Briefcase,
}
@@ -35,9 +36,6 @@ const PROCESS_TYPES: Record<string, { label: string; description: string }> = {
RENEW: { label: '合同续签', description: '到期合同续签' },
SUSPEND: { label: '合同中止', description: '中止履行合同' },
INCOME_CERT: { label: '开具收入证明', description: '为员工开具收入证明' },
TERMINATE: { label: '合同终止', description: '合同到期终止' },
RESCIND: { label: '合同解除', description: '协商或单方解除合同' },
LEAVING_CERT: { label: '开具离职证明', description: '为离职员工开具证明' },
FLEXIBLE: { label: '灵活用工', description: '灵活用工协议签署' },
}
@@ -106,28 +104,16 @@ const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | '
],
INCOME_CERT: [
{ key: 'enterpriseTemplateId', label: '关联企业模板(选填)', type: 'enterprise-template' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select' },
{ key: 'employeeName', label: '员工姓名', type: 'text' },
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
{ key: 'position', label: '职务', type: 'text' },
{ key: 'monthlyIncome', label: '月收入', type: 'text' },
{ key: 'purpose', label: '用途', type: 'text' },
],
TERMINATE: [
{ key: 'employeeId', label: '选择员工', type: 'employee-select' },
{ key: 'contractId', label: '合同ID', type: 'text' },
{ key: 'terminateDate', label: '终止日期', type: 'date' },
{ key: 'reason', label: '终止原因', type: 'select', options: ['EXPIRED', 'NEGOTIATED', 'FAULT', 'NONFAULT', 'LAYOFF'] },
{ key: 'compensation', label: '经济补偿金', type: 'number' },
],
RESCIND: [
{ key: 'employeeId', label: '选择员工', type: 'employee-select' },
{ key: 'contractId', label: '合同ID', type: 'text' },
{ key: 'rescindDate', label: '解除日期', type: 'date' },
{ key: 'reason', label: '解除原因', type: 'select', options: ['NEGOTIATED', 'FAULT', 'NONFAULT', 'LAYOFF'] },
{ key: 'compensation', label: '经济补偿金', type: 'number' },
],
LEAVING_CERT: [
{ key: 'enterpriseTemplateId', label: '关联企业模板(选填)', type: 'enterprise-template' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select' },
{ key: 'employeeName', label: '员工姓名', type: 'text' },
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
{ key: 'position', label: '职务', type: 'text' },
@@ -155,8 +141,15 @@ export default function WorkProcess() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [showCreate, setShowCreate] = useState(false)
const [selectedType, setSelectedType] = useState<string>('')
const [formData, setFormData] = useState<Record<string, any>>({})
const [selectedType, setSelectedType] = useState<string>(() => {
try { return localStorage.getItem('workprocess-draft-type') || '' } catch { return '' }
})
const [formData, setFormData] = useState<Record<string, any>>(() => {
try {
const saved = localStorage.getItem('workprocess-draft-data')
return saved ? JSON.parse(saved) : {}
} catch { return {} }
})
const [filterType, setFilterType] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const [detailId, setDetailId] = useState<string | null>(null)
@@ -168,6 +161,22 @@ export default function WorkProcess() {
const pageSize = usePageSize()
const [page, setPage] = useState(1)
// 持久化草稿到 localStorage,防止录入数据丢失
useEffect(() => {
try {
if (selectedType && Object.keys(formData).length > 0) {
localStorage.setItem('workprocess-draft-type', selectedType)
localStorage.setItem('workprocess-draft-data', JSON.stringify(formData))
} else {
localStorage.removeItem('workprocess-draft-type')
localStorage.removeItem('workprocess-draft-data')
}
} catch {}
}, [selectedType, formData])
const isDirty = selectedType && Object.keys(formData).length > 0
useUnsavedChanges(!!isDirty)
const { data: listData, isLoading, isError, error, refetch } = useQuery({
queryKey: ['work-processes', filterType, filterStatus, page, pageSize],
queryFn: async () => {
@@ -191,7 +200,7 @@ export default function WorkProcess() {
setFormData({})
setSelectedType('')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '创建失败'),
onError: (err: any) => toastError(err, '创建失败'),
})
const submitMutation = useMutation({
@@ -206,7 +215,7 @@ export default function WorkProcess() {
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setDetailId(null)
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '提交失败'),
onError: (err: any) => toastError(err, '提交失败'),
})
const cancelMutation = useMutation({
@@ -218,7 +227,7 @@ export default function WorkProcess() {
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setDetailId(null)
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '撤销失败'),
onError: (err: any) => toastError(err, '撤销失败'),
})
const deleteMutation = useMutation({
@@ -229,7 +238,7 @@ export default function WorkProcess() {
toast.success('已删除')
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
onError: (err: any) => toastError(err, '删除失败'),
})
const previewMutation = useMutation({
@@ -239,7 +248,7 @@ export default function WorkProcess() {
onSuccess: (data) => {
setPreviewContent(data.content)
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '预览失败'),
onError: (err: any) => toastError(err, '预览失败'),
})
const handleCreate = () => {
@@ -282,6 +291,21 @@ export default function WorkProcess() {
const handleFieldChange = (key: string, value: any) => {
setFormData(prev => ({ ...prev, [key]: value }))
// 选择员工后自动填充相关字段
if (key === 'employeeId' && value) {
employeeApi.detail(value).then((emp: any) => {
setFormData(prev => ({
...prev,
employeeName: emp.name || prev.employeeName,
idCardNumber: emp.idCardNumber || prev.idCardNumber,
position: emp.position || prev.position,
monthlyIncome: emp.monthlySalary ? String(emp.monthlySalary) : prev.monthlyIncome,
hireDate: emp.hireDate ? emp.hireDate.slice(0, 10) : prev.hireDate,
department: emp.department || prev.department,
phone: emp.phone || prev.phone,
}))
}).catch(() => {})
}
}
const handleBatchSubmit = () => {
@@ -349,6 +373,14 @@ export default function WorkProcess() {
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium"></h2>
<div className="flex items-center gap-2">
{isDirty && (
<span className="text-xs text-amber-600 flex items-center gap-1">
<FileText className="w-3.5 h-3.5" />
稿{PROCESS_TYPES[selectedType]?.label}
<button className="text-primary hover:underline" onClick={() => { setSelectedType(''); setFormData({}) }}></button>
</span>
)}
<Button size="sm" onClick={() => setShowCreate(true)}>
<UserPlus className="w-4 h-4 mr-1" />
</Button>
@@ -356,6 +388,7 @@ export default function WorkProcess() {
<Users className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{/* 13类流程卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-2">
@@ -452,7 +485,7 @@ export default function WorkProcess() {
</Card>
{/* 创建/编辑弹窗 */}
<Modal open={showCreate} onClose={() => { setShowCreate(false); setFormData({}); setSelectedType('') }} title={selectedType ? `发起:${PROCESS_TYPES[selectedType]?.label}` : '发起办理'} size="lg">
<Modal open={showCreate} onClose={() => setShowCreate(false)} title={selectedType ? `发起:${PROCESS_TYPES[selectedType]?.label}` : '发起办理'} size="lg">
{!selectedType ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{Object.entries(PROCESS_TYPES).map(([key, config]) => {
@@ -492,7 +525,16 @@ export default function WorkProcess() {
) : field.type === 'enterprise-template' ? (
<EnterpriseTemplateSelect value={formData[field.key] || ''} onChange={(v) => handleFieldChange(field.key, v)} />
) : field.type === 'employee-select' ? (
<EmployeeSelect value={formData[field.key] || ''} onChange={(v) => handleFieldChange(field.key, v)} />
<EmployeeSelect value={formData[field.key] || ''} onChange={(emp) => {
handleFieldChange(field.key, emp.id)
if (emp.name) handleFieldChange('employeeName', emp.name)
if (emp.idCardNumber) handleFieldChange('idCardNumber', emp.idCardNumber)
if (emp.position) handleFieldChange('position', emp.position)
if (emp.monthlySalary) handleFieldChange('monthlyIncome', String(emp.monthlySalary))
if (emp.hireDate) handleFieldChange('hireDate', emp.hireDate?.slice(0, 10))
if (emp.department) handleFieldChange('department', emp.department)
if (emp.phone) handleFieldChange('phone', emp.phone)
}} />
) : field.type === 'contract-select' ? (
<ContractSelect value={formData[field.key] || ''} onChange={(v) => handleFieldChange(field.key, v)} employeeId={formData['employeeId'] || ''} />
) : (
@@ -513,7 +555,7 @@ export default function WorkProcess() {
{(createMutation.isPending || submitMutation.isPending) ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Send className="w-4 h-4 mr-1" />}
</Button>
<Button variant="secondary" onClick={() => { setSelectedType(''); setFormData({}) }}>
<Button variant="secondary" onClick={() => setSelectedType('')}>
</Button>
</div>
@@ -743,7 +785,7 @@ function EnterpriseTemplateSelect({ value, onChange }: { value: string; onChange
)
}
function EmployeeSelect({ value, onChange }: { value: string; onChange: (v: string) => void }) {
function EmployeeSelect({ value, onChange }: { value: string; onChange: (employee: any) => void }) {
const [search, setSearch] = useState('')
const [open, setOpen] = useState(false)
const { data: employees = [], isLoading } = useQuery<any[]>({
@@ -792,7 +834,7 @@ function EmployeeSelect({ value, onChange }: { value: string; onChange: (v: stri
key={e.id}
className="px-3 py-2 text-sm hover:bg-primary/5 cursor-pointer flex items-center justify-between"
onClick={() => {
onChange(e.id)
onChange(e)
setOpen(false)
setSearch('')
}}
+78 -4
View File
@@ -1,7 +1,7 @@
import { useState, useRef } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Info, Check, Upload, Settings as SettingsIcon, FileText, X } from 'lucide-react'
import { Info, Check, Upload, Settings as SettingsIcon, FileText, X, Plus } from 'lucide-react'
import PageGuide from '../../components/ui/PageGuide'
import { payrollApi, employeeApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
@@ -18,6 +18,8 @@ export function OvertimeCalculator() {
const [previewData, setPreviewData] = useState<any[]>([])
const [editingId, setEditingId] = useState<string | null>(null)
const [editForm, setEditForm] = useState({ weekdayHours: 0, weekendHours: 0, holidayHours: 0 })
const [showAddForm, setShowAddForm] = useState(false)
const [addForm, setAddForm] = useState({ employeeId: '', weekdayHours: 0, weekendHours: 0, holidayHours: 0 })
// 加班费规则配置
const { data: config, isLoading: configLoading } = useQuery<any>({
@@ -60,6 +62,17 @@ export function OvertimeCalculator() {
},
})
// 从考勤记录同步加班工时
const syncFromAttendanceMutation = useMutation({
mutationFn: (m: string) => payrollApi.syncOvertimeFromAttendance(m),
onSuccess: (data: any) => {
toast.success(`已从考勤同步 ${data.synced || 0} 位员工的加班工时`)
queryClient.invalidateQueries({ queryKey: ['overtime-records'] })
setStep(3)
},
onError: () => toast.error('同步失败'),
})
// 更新单条加班记录
const updateOvertimeMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) =>
@@ -87,6 +100,18 @@ export function OvertimeCalculator() {
}
}
// 手动添加加班记录
const addOvertimeMutation = useMutation({
mutationFn: (data: any) => payrollApi.saveOvertime(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['overtime-records'] })
setShowAddForm(false)
setAddForm({ employeeId: '', weekdayHours: 0, weekendHours: 0, holidayHours: 0 })
toast.success('加班记录已添加')
},
onError: () => toast.error('添加失败'),
})
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
@@ -265,13 +290,18 @@ export function OvertimeCalculator() {
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-48" />
</div>
<div className="border-t pt-3">
<input ref={fileInputRef} type="file" accept=".csv" className="hidden" onChange={handleFileUpload} />
<div className="flex gap-2 items-center">
<input ref={fileInputRef} type="file" accept=".csv,.xlsx,.xls" className="hidden" onChange={handleFileUpload} />
<Button variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={batchImportMutation.isPending}>
<Upload className="w-4 h-4 mr-1" />
{batchImportMutation.isPending ? '导入中...' : '选择CSV文件'}
{batchImportMutation.isPending ? '导入中...' : '选择文件导入'}
</Button>
<Button variant="secondary" onClick={() => syncFromAttendanceMutation.mutate(month)} disabled={syncFromAttendanceMutation.isPending}>
{syncFromAttendanceMutation.isPending ? '同步中...' : '从考勤同步'}
</Button>
</div>
<div className="text-xs text-gray-500 mt-2">
CSV格式,(h),(h),(h),()
,(h),(h),(h),()
</div>
</div>
@@ -329,8 +359,52 @@ export function OvertimeCalculator() {
<div className="flex items-center gap-2">
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-32" />
<Button variant="secondary" size="sm" onClick={() => refetch()}></Button>
<Button size="sm" onClick={() => setShowAddForm(!showAddForm)}><Plus className="w-3.5 h-3.5 mr-1" /></Button>
</div>
</div>
{showAddForm && (
<div className="border rounded-lg p-3 mb-3 bg-gray-50 space-y-3">
<div className="grid md:grid-cols-4 gap-3">
<div>
<Label></Label>
<select
className="w-full h-9 rounded-md border border-gray-200 bg-white px-3 text-sm"
value={addForm.employeeId}
onChange={(e) => setAddForm({ ...addForm, employeeId: e.target.value })}
>
<option value=""></option>
{employees?.items?.map((emp: any) => (
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
))}
</select>
</div>
<div>
<Label>(h)</Label>
<Input type="number" step="0.5" min="0" value={addForm.weekdayHours} onChange={(e) => setAddForm({ ...addForm, weekdayHours: Number(e.target.value) })} />
</div>
<div>
<Label>(h)</Label>
<Input type="number" step="0.5" min="0" value={addForm.weekendHours} onChange={(e) => setAddForm({ ...addForm, weekendHours: Number(e.target.value) })} />
</div>
<div>
<Label>(h)</Label>
<Input type="number" step="0.5" min="0" value={addForm.holidayHours} onChange={(e) => setAddForm({ ...addForm, holidayHours: Number(e.target.value) })} />
</div>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={() => {
if (!addForm.employeeId) return toast.error('请选择员工')
const emp = employees?.items?.find((e: any) => e.id === addForm.employeeId)
let monthlyWage = 0
try { monthlyWage = Number((emp as any)?.monthlySalary) || 0 } catch {}
addOvertimeMutation.mutate({ ...addForm, month, monthlyWage: monthlyWage || 1 })
}} disabled={addOvertimeMutation.isPending || !addForm.employeeId}>
{addOvertimeMutation.isPending ? '保存中...' : '保存'}
</Button>
<Button variant="secondary" size="sm" onClick={() => setShowAddForm(false)}></Button>
</div>
</div>
)}
{!overtimeRecords || overtimeRecords.length === 0 ? (
<div className="text-center py-8 text-gray-500"></div>
) : (
+5 -3
View File
@@ -14,7 +14,7 @@ import Pagination from '../../components/ui/Pagination'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
export function PayslipManager() {
export function PayslipManager({ filterEmployeeId }: { filterEmployeeId?: string }) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
@@ -31,9 +31,11 @@ export function PayslipManager() {
})
const { data: payslips, isLoading } = useQuery<any[]>({
queryKey: ['payslips', month],
queryKey: ['payslips', month, filterEmployeeId],
queryFn: async () => {
return await payrollApi.payslips({ month })
const all = await payrollApi.payslips({ month })
if (!filterEmployeeId) return all
return all.filter((p: any) => p.employeeId === filterEmployeeId)
},
})
+47 -4
View File
@@ -1,8 +1,8 @@
import { QRCodeSVG } from "qrcode.react"
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { attachmentApi, employeeApi } from '../../lib/api-services'
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { attachmentApi, employeeApi, socialInsuranceApi } from '../../lib/api-services'
import { copyToClipboard } from '../../lib/clipboard'
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
@@ -58,6 +58,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
const [form, setForm] = useState({
department: profile.department || '',
position: profile.position || '',
gender: profile.gender || '男',
femaleWorkerType: profile.femaleWorkerType || '',
phone: profile.phone || '',
@@ -86,6 +87,26 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
queryClient.invalidateQueries({ queryKey: ['roster'] })
setEditing(false)
},
onError: (err: any) => {
const details = err?.response?.data?.error?.details
if (details?.length > 0) {
toast.error(details.map((d: any) => `${d.path}: ${d.message}`).join(''))
} else {
toast.error(err?.response?.data?.error?.message || '保存失败')
}
},
})
// 查询社保费用明细(按险种分别计算)
const { data: socialDetail } = useQuery<any>({
queryKey: ['social-calc', profile.id, profile.socialInsBase, profile.city],
queryFn: async () => {
if (!profile.socialInsBase || !profile.city) return null
try {
return await socialInsuranceApi.calculate(Number(profile.socialInsBase), profile.city)
} catch { return null }
},
enabled: !editing && !!profile.socialInsBase && !!profile.city,
})
const handleSave = () => {
@@ -113,6 +134,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
specialDeduction: Number(form.specialDeduction) || 0,
city: form.city || undefined,
education: form.education || undefined,
position: form.position || undefined,
cityChangeReason: form.city !== profile.city ? form.cityChangeReason || undefined : undefined,
}
updateMutation.mutate(data)
@@ -128,6 +150,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
{ label: '身份证号', value: profile.idCardNumber || '未填写' },
{ label: '手机号', value: profile.phone || '未填写' },
{ label: '学历', value: profile.education || '未填写' },
{ label: '职务/岗位', value: profile.position || '未填写' },
{ label: '入职日期', value: profile.hireDate?.toString().slice(0, 10) },
{ label: '状态', value: profile.status === 'ACTIVE' ? '在职' : '离职' },
...(profile.retirementDaysLeft != null
@@ -302,6 +325,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
)}
<div><Label></Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></div>
<div><Label></Label><Select value={form.education} onChange={(e) => setForm({ ...form, education: e.target.value })}><option value=""></option><option value="博士"></option><option value="硕士"></option><option value="本科"></option><option value="大专"></option><option value="高中"></option><option value="其他"></option></Select></div>
<div><Label>/</Label><Input value={form.position} onChange={(e) => setForm({ ...form, position: e.target.value })} placeholder="如:前端工程师" /></div>
<div><Label></Label><Input type="date" value={form.hireDate} onChange={(e) => setForm({ ...form, hireDate: e.target.value })} /></div>
<div><Label></Label><Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: Number(e.target.value) })} /></div>
<div><Label></Label><Input value={form.emergencyContact} onChange={(e) => setForm({ ...form, emergencyContact: e.target.value })} placeholder="选填" /></div>
@@ -333,6 +357,25 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
<span className="text-gray-500"></span>
<span className="font-medium">{profile.specialDeduction ? `¥${fmt(profile.specialDeduction)}/月` : '¥0.00/月'}</span>
</div>
{socialDetail?.items?.length > 0 && (
<div className="md:col-span-4 mt-2">
<div className="text-xs font-medium text-gray-600 mb-2"></div>
<div className="grid md:grid-cols-5 gap-2">
{socialDetail.items.map((item: any) => (
<div key={item.name} className="px-2 py-1.5 rounded bg-gray-50 text-xs">
<div className="font-medium text-gray-700">{item.name}</div>
<div className="text-gray-500 mt-0.5"> ¥{fmt(item.orgAmount)}{item.orgRate}%</div>
<div className="text-gray-500"> ¥{fmt(item.empAmount)}{item.empRate}%</div>
</div>
))}
</div>
{socialDetail.capped && <div className="text-xs text-amber-600 mt-1"> ¥{fmt(socialDetail.actualBase)}</div>}
{socialDetail.floored && <div className="text-xs text-amber-600 mt-1"> ¥{fmt(socialDetail.actualBase)}</div>}
{socialDetail.medicalBase && socialDetail.medicalBase !== socialDetail.actualBase && (
<div className="text-xs text-blue-600 mt-1">¥{fmt(socialDetail.medicalBase)}</div>
)}
</div>
)}
</div>
) : (
<div className="grid md:grid-cols-4 gap-4">
@@ -342,11 +385,11 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
</div>
<div>
<Label></Label>
<Input type="number" placeholder="按人核定" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} />
<Input type="number" placeholder="按人核定" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} onFocus={(e) => e.target.select()} />
</div>
<div>
<Label></Label>
<Input type="number" placeholder="按人核定" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} />
<Input type="number" placeholder="按人核定" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} onFocus={(e) => e.target.select()} />
</div>
<div>
<Label>/</Label>
+61 -6
View File
@@ -1,4 +1,5 @@
import { useState, useRef } from "react"
import mammoth from "mammoth"
import api from '../../lib/api'
import { toast } from "sonner"
import { useMutation, useQueryClient } from "@tanstack/react-query"
@@ -18,18 +19,36 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
const supplementFileRefs = useRef<Record<string, HTMLInputElement | null>>({})
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('附件')
const [wordHtml, setWordHtml] = useState<string | null>(null)
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('附件已上传')
queryClient.invalidateQueries({ queryKey: ['employee-detail'] })
toast.success('附件已更新')
},
onError: () => toast.error('上传失败'),
})
const deleteAttachmentMutation = useMutation({
mutationFn: async ({ contractId, attachmentUrl }: { contractId: string; attachmentUrl: string }) => {
await api.patch(`/employees/contracts/${contractId}/attachment`, { attachmentUrl })
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['employee-detail'] })
toast.success('附件已删除')
},
onError: () => toast.error('删除失败'),
})
const handleDeleteAttachment = async (contractId: string, atts: { name: string; url: string }[], idx: number) => {
if (!await confirm({ title: '删除附件', message: '确定删除此附件?删除后不可恢复。' })) return
const newAtts = atts.filter((_, i) => i !== idx)
deleteAttachmentMutation.mutate({ contractId, attachmentUrl: newAtts.length > 0 ? JSON.stringify(newAtts) : '' })
}
const handleSupplementUpload = (e: React.ChangeEvent<HTMLInputElement>, contractId: string, existingAtts: { name: string; url: string }[]) => {
const files = e.target.files
if (!files || files.length === 0) return
@@ -332,9 +351,10 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
<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>
<div className="flex items-center gap-1 ml-2 shrink-0">
<button
type="button"
className="text-gray-400 hover:text-primary ml-2 shrink-0"
className="text-gray-400 hover:text-primary"
title="下载附件"
onClick={() => {
const dataToBlobUrl = (dataUrl: string) => {
@@ -357,6 +377,16 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
>
<Download className="w-3 h-3" />
</button>
<button
type="button"
className="text-gray-400 hover:text-danger"
title="删除附件"
disabled={deleteAttachmentMutation.isPending}
onClick={() => handleDeleteAttachment(c.id, atts, idx)}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
))}
<input id={`contract-file-${c.id}`} type="file" multiple className="hidden" onChange={(e) => handleSupplementUpload(e, c.id, atts)} />
@@ -405,12 +435,28 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
const mime = previewUrl.startsWith('data:') ? previewUrl.match(/data:(.*?);/)?.[1] || '' : ''
const isImage = mime.startsWith('image/')
const isPdf = mime === 'application/pdf'
const isWord = mime === 'application/msword' || mime === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || previewName.endsWith('.doc') || previewName.endsWith('.docx')
// 如果是 Word 文件且尚未转换,异步转换
if (isWord && !wordHtml) {
fetch(blobUrl)
.then(r => r.arrayBuffer())
.then(buf => mammoth.convertToHtml({ arrayBuffer: buf }))
.then(result => setWordHtml(result.value))
.catch(() => setWordHtml('<p style="text-align:center;color:#999;">Word 文件转换失败,请下载查看</p>'))
}
const closePreview = () => {
if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl)
setPreviewUrl(null)
setWordHtml(null)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => { if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }}>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={closePreview}>
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-4 py-2 border-b">
<span className="text-sm font-medium"></span>
<span className="text-sm font-medium"> - {previewName}</span>
<div className="flex items-center gap-2">
<button type="button" onClick={() => {
const a = document.createElement('a')
@@ -422,7 +468,7 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
}} className="text-xs text-primary hover:underline flex items-center gap-1">
<Download className="w-3 h-3" />
</button>
<button onClick={() => { if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }} className="text-gray-400 hover:text-gray-600">
<button onClick={closePreview} className="text-gray-400 hover:text-gray-600">
<X className="w-4 h-4" />
</button>
</div>
@@ -432,6 +478,15 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
<img src={blobUrl} alt="附件预览" className="max-w-full max-h-full object-contain" />
) : isPdf ? (
<embed src={blobUrl} type="application/pdf" className="w-full h-full" />
) : isWord ? (
wordHtml ? (
<div className="prose prose-sm max-w-none w-full" dangerouslySetInnerHTML={{ __html: wordHtml }} />
) : (
<div className="text-center space-y-3">
<div className="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full mx-auto" />
<p className="text-sm text-gray-500"> Word ...</p>
</div>
)
) : (
<div className="text-center space-y-3">
<FileText className="w-12 h-12 text-gray-300 mx-auto" />
@@ -63,7 +63,7 @@ export default function EmployeeProfile({ employeeId, onBack }: { employeeId: st
<>
{activeTab === 'basic' && <BasicInfo profile={profile} employeeId={employeeId} attachments={profile.attachments} />}
{activeTab === 'contract' && <ContractInfo employeeId={employeeId} contracts={profile.contracts} hireDate={profile.hireDate} />}
{activeTab === 'payslip' && <PayslipSocialInfo payslips={profile.payslips} socialInsRecords={profile.socialInsRecords} housingFundRecords={profile.housingFundRecords} monthlyProcessRecords={profile.monthlyProcessRecords} />}
{activeTab === 'payslip' && <PayslipSocialInfo payslips={profile.payslips} socialInsRecords={profile.socialInsRecords} housingFundRecords={profile.housingFundRecords} monthlyProcessRecords={profile.monthlyProcessRecords} employeeId={employeeId} />}
{activeTab === 'disciplinary' && <DisciplinaryInfo employeeId={employeeId} records={profile.disciplinaryRecords} />}
{activeTab === 'attendance' && <AttendanceOvertimeInfo employeeId={employeeId} attendanceRecords={profile.attendanceRecords} overtimeRecords={profile.overtimeRecords} trainingRecords={profile.trainingRecords} />}
{activeTab === 'performance' && <PerformanceInfo employeeId={employeeId} records={profile.performanceRecords} />}
+69 -2
View File
@@ -1,14 +1,18 @@
import { useState } from "react"
import { toast } from "sonner"
import { useQuery } from "@tanstack/react-query"
import { rosterApi } from '../../lib/api-services'
import { rosterApi, evidenceApi } from '../../lib/api-services'
import { useAuthStore } from "../../store/authStore"
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
import { AlertTriangle, Scale } from "lucide-react"
import { AlertTriangle, Scale, ShieldCheck } from "lucide-react"
// ========== 仲裁证据链 ==========
export default function EvidenceChain({ employeeId }: { employeeId: string }) {
const [verifyResult, setVerifyResult] = useState<any>(null)
const [verifying, setVerifying] = useState(false)
const { data, isLoading } = useQuery<any>({
queryKey: ['evidence-chain', employeeId],
queryFn: async () => {
@@ -16,6 +20,24 @@ export default function EvidenceChain({ employeeId }: { employeeId: string }) {
},
})
const handleVerify = async () => {
setVerifying(true)
try {
const records = await evidenceApi.byEmployee(employeeId)
const results: any[] = []
for (const r of records) {
const result = await evidenceApi.verify(r.id)
results.push({ id: r.id, category: r.category, refId: r.refId, ...result })
}
setVerifyResult({ total: records.length, valid: results.filter(r => r.valid).length, invalid: results.filter(r => !r.valid).length, details: results })
toast.success(`验证完成:${results.filter(r => r.valid).length}/${results.length} 条有效`)
} catch {
toast.error('验证失败')
} finally {
setVerifying(false)
}
}
if (isLoading) return <div className="text-center py-8 text-gray-400">...</div>
if (!data) return <div className="text-center py-8 text-gray-400"></div>
if (!data.evidence || data.evidence.length === 0) return (
@@ -104,6 +126,10 @@ export default function EvidenceChain({ employeeId }: { employeeId: string }) {
</div>
)}
<Button onClick={handleExport}></Button>
<Button variant="secondary" onClick={handleVerify} disabled={verifying}>
<ShieldCheck className="w-4 h-4 mr-1" />
{verifying ? '验证中...' : '验证完整性'}
</Button>
</div>
</div>
</Card>
@@ -129,6 +155,47 @@ export default function EvidenceChain({ employeeId }: { employeeId: string }) {
</Card>
)}
{verifyResult && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-safe" />
</h3>
<div className="flex items-center gap-4 mb-3">
<div className="text-xs text-center">
<div className="text-gray-500"></div>
<div className="text-lg font-bold">{verifyResult.total}</div>
</div>
<div className="text-xs text-center">
<div className="text-gray-500"></div>
<div className="text-lg font-bold text-safe">{verifyResult.valid}</div>
</div>
{verifyResult.invalid > 0 && (
<div className="text-xs text-center">
<div className="text-gray-500"></div>
<div className="text-lg font-bold text-danger">{verifyResult.invalid}</div>
</div>
)}
</div>
{verifyResult.invalid > 0 && (
<div className="space-y-1">
{verifyResult.details.filter((r: any) => !r.valid).map((r: any, i: number) => (
<div key={i} className="text-xs border rounded p-2 bg-red-50 border-red-200 text-red-700">
<span className="font-medium">{r.category}</span>
{r.refId && <span className="text-xs opacity-70 ml-2">ID: {r.refId}</span>}
<div className="mt-0.5 opacity-90"> {r.expectedHash?.slice(0, 16)}... {r.actualHash?.slice(0, 16)}...</div>
</div>
))}
</div>
)}
{verifyResult.invalid === 0 && (
<div className="text-xs text-safe flex items-center gap-1">
<ShieldCheck className="w-3.5 h-3.5" />
</div>
)}
</Card>
)}
<div className="space-y-2">
{data.evidence.map((e: any, i: number) => (
<Card key={i} className={e.riskLevel === 'HIGH' ? 'border-orange-300' : ''}>
@@ -1,4 +1,5 @@
import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { socialInsuranceApi } from '../../lib/api-services'
@@ -7,13 +8,16 @@ import Button from "../../components/ui/Button"
import { Input, Label, Select } from "../../components/ui/Input"
import Modal from "../../components/ui/Modal"
import { fmt } from "./shared"
import { ExternalLink } from "lucide-react"
/** 薪酬社保合并组件(工资条 / 缴纳记录) */
export default function PayslipSocialInfo({ payslips, monthlyProcessRecords }: { payslips: any[]; socialInsRecords: any[]; housingFundRecords: any[]; monthlyProcessRecords: any[] }) {
export default function PayslipSocialInfo({ payslips, monthlyProcessRecords, employeeId }: { payslips: any[]; socialInsRecords: any[]; housingFundRecords: any[]; monthlyProcessRecords: any[]; employeeId?: string }) {
const [subTab, setSubTab] = useState<'payslip' | 'monthly'>('payslip')
const navigate = useNavigate()
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex gap-1">
<button
onClick={() => setSubTab('payslip')}
@@ -28,6 +32,13 @@ export default function PayslipSocialInfo({ payslips, monthlyProcessRecords }: {
{monthlyProcessRecords?.length || 0}
</button>
</div>
{employeeId && (
<Button variant="secondary" size="sm" onClick={() => navigate(`/money?employeeId=${employeeId}&tab=payslip`)}>
<ExternalLink className="w-3.5 h-3.5 mr-1" />
</Button>
)}
</div>
{subTab === 'payslip' && (
<>
+76 -5
View File
@@ -1,5 +1,5 @@
import { useState } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { rosterApi } from '../../lib/api-services'
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
@@ -11,11 +11,17 @@ 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: '', periodType: 'MONTHLY' as 'MONTHLY' | 'QUARTERLY' | 'YEARLY', 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: '', templateId: '' })
const [dimensionScores, setDimensionScores] = useState<Record<string, number>>({})
const { data: templates } = useQuery({
queryKey: ['performance-templates'],
queryFn: () => rosterApi.performanceTemplates(),
})
const createMutation = useMutation({
mutationFn: (data: any) => rosterApi.performance(employeeId, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false); setDimensionScores({}) },
})
const deleteMutation = useMutation({
@@ -38,6 +44,32 @@ export default function PerformanceInfo({ employeeId, records }: { employeeId: s
setForm({ ...form, score, grade, result })
}
const selectedTemplate = (templates || []).find((t: any) => t.id === form.templateId)
const dimensions: any[] = selectedTemplate?.dimensions || []
const handleDimensionChange = (name: string, score: number) => {
const updated = { ...dimensionScores, [name]: score }
setDimensionScores(updated)
if (dimensions.length > 0) {
const totalScore = dimensions.reduce((sum: number, d: any) => {
const s = updated[d.name] ?? 0
const weight = d.weight || 0
const maxScore = d.maxScore || 100
return sum + (s / maxScore) * weight * 100
}, 0)
const { grade, result } = scoreToGrade(Math.round(totalScore))
setForm(prev => ({ ...prev, score: Math.round(totalScore), grade, result }))
}
}
const handleSubmit = () => {
const data: any = { ...form }
if (form.templateId) {
data.dimensionScores = dimensionScores
}
createMutation.mutate(data)
}
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
@@ -56,26 +88,58 @@ export default function PerformanceInfo({ employeeId, records }: { employeeId: s
</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 className="md:col-span-2"><Label></Label>
<Select value={form.templateId} onChange={(e) => { setForm({ ...form, templateId: e.target.value }); setDimensionScores({}) }}>
<option value="">使</option>
{(templates || []).map((t: any) => (
<option key={t.id} value={t.id}>{t.name}{t.isDefault ? '(默认)' : ''}</option>
))}
</Select>
</div>
{dimensions.length > 0 ? (
<div className="md:col-span-2 border border-gray-200 rounded-md p-3 space-y-2">
<div className="text-xs font-medium text-gray-600"></div>
{dimensions.map((d: any) => (
<div key={d.name} className="grid grid-cols-12 gap-2 items-center">
<div className="col-span-5">
<span className="text-sm">{d.name}</span>
<span className="text-xs text-gray-400 ml-1">{d.weight}%</span>
</div>
<div className="col-span-4">
<Input type="number" min={0} max={d.maxScore || 100} value={dimensionScores[d.name] ?? ''} onChange={(e) => handleDimensionChange(d.name, Number(e.target.value))} placeholder={`满分${d.maxScore || 100}`} className="text-sm" />
</div>
<div className="col-span-3 text-xs text-gray-400">/{d.maxScore || 100}</div>
</div>
))}
<div className="grid grid-cols-2 gap-3 pt-2 border-t">
<div><Label></Label><Input type="number" value={form.score} readOnly className="bg-gray-50" /></div>
<div><Label></Label><Input value={form.grade} readOnly className="bg-gray-50" /></div>
</div>
</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>
<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>
</div>
<div><Label></Label><Input value={form.reviewer} onChange={(e) => setForm({ ...form, reviewer: e.target.value })} /></div>
<div className="md:col-span-2"><Label></Label><Input value={form.summary} onChange={(e) => setForm({ ...form, summary: e.target.value })} /></div>
<div className="md:col-span-2"><Label></Label><Input value={form.improvementPlan} onChange={(e) => setForm({ ...form, improvementPlan: e.target.value })} placeholder="如:调岗至XX岗位,培训XX技能" /></div>
<div><Label></Label><Input value={form.reviewer} onChange={(e) => setForm({ ...form, reviewer: e.target.value })} /></div>
<div className="flex items-center gap-2 pt-6">
<input type="checkbox" id="perfAck" checked={form.employeeAck} onChange={(e) => setForm({ ...form, employeeAck: e.target.checked })} />
<label htmlFor="perfAck" className="text-xs"></label>
</div>
{form.employeeAck && <div><Label></Label><Input type="date" value={form.ackDate} onChange={(e) => setForm({ ...form, ackDate: e.target.value })} /></div>}
<div className="md:col-span-2 flex gap-2"><Button onClick={() => createMutation.mutate(form)} disabled={createMutation.isPending || !form.period}>{createMutation.isPending ? '保存中...' : '保存'}</Button><Button variant="secondary" onClick={() => setShowForm(false)}></Button></div>
<div className="md:col-span-2 flex gap-2"><Button onClick={handleSubmit} disabled={createMutation.isPending || !form.period}>{createMutation.isPending ? '保存中...' : '保存'}</Button><Button variant="secondary" onClick={() => setShowForm(false)}></Button></div>
</div>
</Card>
)}
@@ -93,6 +157,13 @@ export default function PerformanceInfo({ employeeId, records }: { employeeId: s
</span>
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 text-xs"> {r.score} · {r.grade}</span>
</div>
{r.dimensionScores && Object.keys(r.dimensionScores).length > 0 && (
<div className="flex flex-wrap gap-1">
{Object.entries(r.dimensionScores).map(([name, score]: [string, any]) => (
<span key={name} className="text-xs px-2 py-0.5 rounded bg-gray-50 text-gray-600">{name}: {score}</span>
))}
</div>
)}
{r.summary && <div className="text-xs text-gray-600 leading-relaxed">{r.summary}</div>}
{r.improvementPlan && (
<div className="text-xs bg-amber-50 text-amber-700 px-2 py-1.5 rounded leading-relaxed">
@@ -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, LayoutTemplate } from 'lucide-react'
import { toast } from 'sonner'
import { rosterApi, employeeApi } from '../../lib/api-services'
import api from '../../lib/api'
@@ -19,6 +19,7 @@ export default function PerformanceRecords() {
const [keyword, setKeyword] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [editRecord, setEditRecord] = useState<any>(null)
const [showTemplateModal, setShowTemplateModal] = useState(false)
const { data, isLoading } = useQuery({
queryKey: ['performance-list', page, pageSize, keyword],
@@ -30,6 +31,11 @@ export default function PerformanceRecords() {
queryFn: () => employeeApi.list({ status: 'ACTIVE' }),
})
const { data: templates } = useQuery({
queryKey: ['performance-templates'],
queryFn: () => rosterApi.performanceTemplates(),
})
const saveMut = useMutation({
mutationFn: (data: any) => {
const empId = data.employeeId
@@ -71,10 +77,15 @@ export default function PerformanceRecords() {
<h1 className="text-base font-semibold"></h1>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => setShowTemplateModal(true)}>
<LayoutTemplate className="w-4 h-4 mr-1" />
</Button>
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-xs">
@@ -163,17 +174,26 @@ export default function PerformanceRecords() {
{(showCreate || editRecord) && (
<PerformanceForm
employees={employees || []}
templates={templates || []}
record={editRecord}
onSubmit={(data) => saveMut.mutate(data)}
onClose={() => { setShowCreate(false); setEditRecord(null) }}
/>
)}
{showTemplateModal && (
<TemplateModal
templates={templates || []}
onClose={() => setShowTemplateModal(false)}
/>
)}
</div>
)
}
function PerformanceForm({ employees, record, onSubmit, onClose }: {
function PerformanceForm({ employees, templates, record, onSubmit, onClose }: {
employees: any[]
templates: any[]
record: any
onSubmit: (data: any) => void
onClose: () => void
@@ -188,7 +208,12 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: {
summary: record?.summary || '',
improvementPlan: record?.improvementPlan || '',
reviewer: record?.reviewer || '',
templateId: record?.templateId || '',
})
const [dimensionScores, setDimensionScores] = useState<Record<string, number>>(record?.dimensionScores || {})
const selectedTemplate = templates.find((t: any) => t.id === form.templateId)
const dimensions: any[] = selectedTemplate?.dimensions || []
const scoreToGrade = (score: number): { grade: string; result: string } => {
if (score >= 90) return { grade: 'A', result: 'EXCELLENT' }
@@ -202,6 +227,31 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: {
setForm({ ...form, score, grade, result })
}
const handleDimensionChange = (name: string, score: number) => {
const updated = { ...dimensionScores, [name]: score }
setDimensionScores(updated)
// 按权重计算总分
if (dimensions.length > 0) {
const totalScore = dimensions.reduce((sum: number, d: any) => {
const s = updated[d.name] ?? 0
const weight = d.weight || 0
const maxScore = d.maxScore || 100
return sum + (s / maxScore) * weight * 100
}, 0)
const { grade, result } = scoreToGrade(Math.round(totalScore))
setForm(prev => ({ ...prev, score: Math.round(totalScore), grade, result }))
}
}
const handleSubmit = () => {
const data: any = { ...form }
if (form.templateId) {
data.templateId = form.templateId
data.dimensionScores = dimensionScores
}
onSubmit(data)
}
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()}>
@@ -235,6 +285,34 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: {
<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} />
</div>
<div>
<Label></Label>
<Select value={form.templateId} onChange={(e) => { setForm({ ...form, templateId: e.target.value }); setDimensionScores({}) }}>
<option value="">使</option>
{templates.map((t: any) => (
<option key={t.id} value={t.id}>{t.name}{t.isDefault ? '(默认)' : ''}</option>
))}
</Select>
</div>
{dimensions.length > 0 ? (
<div className="border border-gray-200 rounded-md p-3 space-y-2">
<div className="text-xs font-medium text-gray-600"></div>
{dimensions.map((d: any) => (
<div key={d.name} className="grid grid-cols-12 gap-2 items-center">
<div className="col-span-5">
<span className="text-sm">{d.name}</span>
{d.description && <span className="text-xs text-gray-400 ml-1">({d.description})</span>}
<span className="text-xs text-gray-400 ml-1">{d.weight}%</span>
</div>
<div className="col-span-4">
<Input type="number" min={0} max={d.maxScore || 100} value={dimensionScores[d.name] ?? ''} onChange={(e) => handleDimensionChange(d.name, Number(e.target.value))} placeholder={`满分${d.maxScore || 100}`} className="text-sm" />
</div>
<div className="col-span-3 text-xs text-gray-400">/{d.maxScore || 100}</div>
</div>
))}
<div className="text-xs text-gray-500 pt-1 border-t"></div>
</div>
) : (
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
@@ -250,6 +328,19 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: {
</Select>
</div>
</div>
)}
{dimensions.length > 0 && (
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" value={form.score} readOnly className="bg-gray-50" />
</div>
<div>
<Label></Label>
<Input value={form.grade} readOnly className="bg-gray-50" />
</div>
</div>
)}
<div>
<Label></Label>
<Select value={form.result} onChange={(e) => setForm({ ...form, result: e.target.value })}>
@@ -285,10 +376,200 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: {
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" size="sm" onClick={onClose}></Button>
<Button size="sm" onClick={() => onSubmit(form)} disabled={!form.employeeId || !form.period}></Button>
<Button size="sm" onClick={handleSubmit} disabled={!form.employeeId || !form.period}></Button>
</div>
</div>
</div>
</div>
)
}
function TemplateModal({ templates, onClose }: {
templates: any[]
onClose: () => void
}) {
const queryClient = useQueryClient()
const [editing, setEditing] = useState<any>(null)
const [showForm, setShowForm] = useState(false)
const createMut = useMutation({
mutationFn: (data: any) => rosterApi.createPerformanceTemplate(data),
onSuccess: () => {
toast.success('模板已创建')
queryClient.invalidateQueries({ queryKey: ['performance-templates'] })
setShowForm(false)
},
onError: () => toast.error('创建失败'),
})
const updateMut = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => rosterApi.updatePerformanceTemplate(id, data),
onSuccess: () => {
toast.success('模板已更新')
queryClient.invalidateQueries({ queryKey: ['performance-templates'] })
setShowForm(false)
setEditing(null)
},
onError: () => toast.error('更新失败'),
})
const deleteMut = useMutation({
mutationFn: (id: string) => rosterApi.deletePerformanceTemplate(id),
onSuccess: () => {
toast.success('模板已删除')
queryClient.invalidateQueries({ queryKey: ['performance-templates'] })
},
})
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-2xl max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium"></h3>
<div className="flex gap-2">
<Button size="sm" onClick={() => { setEditing(null); setShowForm(true) }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
<button onClick={onClose}><X className="w-4 h-4 text-gray-400" /></button>
</div>
</div>
{showForm ? (
<TemplateForm
template={editing}
onSubmit={(data) => {
if (editing) {
updateMut.mutate({ id: editing.id, data })
} else {
createMut.mutate(data)
}
}}
onClose={() => { setShowForm(false); setEditing(null) }}
/>
) : (
<div className="space-y-2">
{templates.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm">
</div>
) : templates.map((t: any) => (
<div key={t.id} className="border border-gray-200 rounded-md p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{t.name}</span>
{t.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={() => { setEditing(t); setShowForm(true) }} className="p-1 hover:bg-gray-100 rounded">
<Edit2 className="w-3.5 h-3.5 text-gray-500" />
</button>
<button
onClick={() => { if (confirm('确认删除此模板?')) deleteMut.mutate(t.id) }}
className="p-1 hover:bg-gray-100 rounded"
>
<Trash2 className="w-3.5 h-3.5 text-red-400" />
</button>
</div>
</div>
{t.description && <p className="text-xs text-gray-500 mt-1">{t.description}</p>}
<div className="flex flex-wrap gap-1 mt-2">
{(t.dimensions as any[]).map((d: any) => (
<span key={d.name} className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600">
{d.name}{d.weight}%
</span>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
function TemplateForm({ template, onSubmit, onClose }: {
template: any
onSubmit: (data: any) => void
onClose: () => void
}) {
const [name, setName] = useState(template?.name || '')
const [description, setDescription] = useState(template?.description || '')
const [isDefault, setIsDefault] = useState(template?.isDefault || false)
const [dimensions, setDimensions] = useState<any[]>(
template?.dimensions || [{ name: '', weight: 100, maxScore: 100, description: '' }]
)
const addDimension = () => {
setDimensions([...dimensions, { name: '', weight: 0, maxScore: 100, description: '' }])
}
const removeDimension = (idx: number) => {
setDimensions(dimensions.filter((_, i) => i !== idx))
}
const updateDimension = (idx: number, field: string, value: any) => {
setDimensions(dimensions.map((d, i) => i === idx ? { ...d, [field]: value } : d))
}
const totalWeight = dimensions.reduce((sum, d) => sum + (Number(d.weight) || 0), 0)
const canSubmit = name && dimensions.every(d => d.name) && totalWeight === 100
return (
<div className="space-y-3">
<div>
<Label> *</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="如:月度绩效考核表" />
</div>
<div>
<Label></Label>
<Input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="模板用途说明(选填)" />
</div>
<div>
<Label> *</Label>
<div className="space-y-2">
{dimensions.map((d, idx) => (
<div key={idx} className="grid grid-cols-12 gap-2 items-center border border-gray-200 rounded p-2">
<div className="col-span-3">
<Input value={d.name} onChange={(e) => updateDimension(idx, 'name', e.target.value)} placeholder="维度名称" className="text-sm" />
</div>
<div className="col-span-2">
<Input type="number" min={0} max={100} value={d.weight} onChange={(e) => updateDimension(idx, 'weight', Number(e.target.value))} placeholder="权重%" className="text-sm" />
</div>
<div className="col-span-2">
<Input type="number" min={1} value={d.maxScore} onChange={(e) => updateDimension(idx, 'maxScore', Number(e.target.value))} placeholder="满分" className="text-sm" />
</div>
<div className="col-span-4">
<Input value={d.description || ''} onChange={(e) => updateDimension(idx, 'description', e.target.value)} placeholder="说明(选填)" className="text-sm" />
</div>
<div className="col-span-1">
{dimensions.length > 1 && (
<button onClick={() => removeDimension(idx)} className="p-1 hover:bg-gray-100 rounded">
<X className="w-3.5 h-3.5 text-red-400" />
</button>
)}
</div>
</div>
))}
</div>
<div className="flex items-center justify-between mt-2">
<button onClick={addDimension} className="text-xs text-primary hover:underline">+ </button>
<span className={`text-xs ${totalWeight === 100 ? 'text-green-600' : 'text-amber-600'}`}>{totalWeight}%</span>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} />
</label>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" size="sm" onClick={onClose}></Button>
<Button size="sm" onClick={() => onSubmit({ name, description, dimensions, isDefault })} disabled={!canSubmit}>
</Button>
</div>
{!canSubmit && totalWeight !== 100 && (
<div className="text-xs text-amber-600">100%</div>
)}
</div>
)
}
+115 -5
View File
@@ -1,13 +1,14 @@
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, Bell, Users, Check } from 'lucide-react'
import { toast } from 'sonner'
import { rosterApi, employeeApi } from '../../lib/api-services'
import api from '../../lib/api'
import { usePageSize } from '../../hooks/usePageSize'
import { Input, Label, Select } from '../../components/ui/Input'
import Button from '../../components/ui/Button'
import Modal from '../../components/ui/Modal'
const ACK_LABELS: Record<string, string> = { PENDING: '待签收', SIGNED: '已签收', REFUSED: '拒绝签收' }
const ACK_COLORS: Record<string, string> = { PENDING: 'bg-amber-50 text-amber-700', SIGNED: 'bg-green-50 text-green-700', REFUSED: 'bg-red-50 text-red-700' }
@@ -22,12 +23,13 @@ export default function TrainingRecords() {
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [keyword, setKeyword] = useState('')
const [filterAckStatus, setFilterAckStatus] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [editRecord, setEditRecord] = useState<any>(null)
const { data, isLoading } = useQuery({
queryKey: ['training-list', page, pageSize, keyword],
queryFn: () => rosterApi.trainingList({ page, pageSize, keyword }),
queryKey: ['training-list', page, pageSize, keyword, filterAckStatus],
queryFn: () => rosterApi.trainingList({ page, pageSize, keyword, ackStatus: filterAckStatus }),
})
const { data: employees } = useQuery({
@@ -49,6 +51,16 @@ export default function TrainingRecords() {
onError: () => toast.error('添加失败'),
})
const batchCreateMut = useMutation({
mutationFn: (data: any) => api.post('/roster/training/batch', data),
onSuccess: (data: any) => {
toast.success(`已为 ${data?.count || 0} 名员工添加培训记录`)
queryClient.invalidateQueries({ queryKey: ['training-list'] })
setShowCreate(false)
},
onError: () => toast.error('批量添加失败'),
})
const updateMut = useMutation({
mutationFn: (data: any) => {
const empId = data.employeeId
@@ -74,6 +86,14 @@ export default function TrainingRecords() {
},
})
const remindMut = useMutation({
mutationFn: (recordId: string) => rosterApi.trainingRemind(recordId),
onSuccess: (data: any) => {
toast.success(data?.message || '催办已发送')
},
onError: () => toast.error('催办失败'),
})
const records = data?.records || []
const total = data?.total || 0
const totalPages = Math.ceil(total / pageSize)
@@ -100,6 +120,16 @@ export default function TrainingRecords() {
className="pl-9"
/>
</div>
<Select
value={filterAckStatus}
onChange={(e) => { setFilterAckStatus(e.target.value); setPage(1) }}
className="w-32"
>
<option value=""></option>
<option value="PENDING"></option>
<option value="SIGNED"></option>
<option value="REFUSED"></option>
</Select>
</div>
<div className="overflow-x-auto">
@@ -138,6 +168,16 @@ export default function TrainingRecords() {
</td>
<td className="py-2 pr-4">
<div className="flex gap-1">
{r.ackStatus === 'PENDING' && (
<button
onClick={() => remindMut.mutate(r.id)}
disabled={remindMut.isPending}
className="p-1 hover:bg-gray-100 rounded"
title="催办签收"
>
<Bell className="w-3.5 h-3.5 text-amber-500" />
</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>
@@ -173,6 +213,8 @@ export default function TrainingRecords() {
onSubmit={(data) => {
if (editRecord) {
updateMut.mutate({ ...data, employeeId: editRecord.employeeId, recordId: editRecord.id })
} else if (data.employeeIds) {
batchCreateMut.mutate(data)
} else {
createMut.mutate(data)
}
@@ -190,6 +232,9 @@ function TrainingForm({ employees, record, onSubmit, onClose }: {
onSubmit: (data: any) => void
onClose: () => void
}) {
const [batchMode, setBatchMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [batchSearch, setBatchSearch] = useState('')
const [form, setForm] = useState({
employeeId: record?.employeeId || '',
trainingDate: record?.trainingDate ? new Date(record.trainingDate).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10),
@@ -200,6 +245,24 @@ function TrainingForm({ employees, record, onSubmit, onClose }: {
remark: record?.remark || '',
})
const filteredEmployees = batchSearch
? employees.filter((e: any) => e.name.includes(batchSearch) || (e.department || '').includes(batchSearch))
: employees
const toggleEmployee = (id: string) => {
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
}
const handleSubmit = () => {
if (batchMode) {
onSubmit({ ...form, employeeIds: selectedIds })
} else {
onSubmit(form)
}
}
const canSubmit = batchMode ? selectedIds.length > 0 && form.topic : form.employeeId && form.topic
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()}>
@@ -210,13 +273,60 @@ function TrainingForm({ employees, record, onSubmit, onClose }: {
<div className="space-y-3">
{!record && (
<div>
<Label></Label>
<div className="flex items-center justify-between mb-1">
<Label>{batchMode ? '批量选择员工' : '员工'}</Label>
<button
className="text-xs text-primary hover:underline flex items-center gap-1"
onClick={() => { setBatchMode(!batchMode); setSelectedIds([]) }}
>
<Users className="w-3.5 h-3.5" />
{batchMode ? '切换为单选' : '切换为批量'}
</button>
</div>
{batchMode ? (
<div className="border border-gray-200 rounded-md">
<div className="p-2 border-b border-gray-100">
<input
type="text"
placeholder="搜索姓名/部门"
value={batchSearch}
onChange={(e) => setBatchSearch(e.target.value)}
className="w-full px-2 py-1 text-sm border border-gray-200 rounded focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="max-h-[180px] overflow-y-auto">
{filteredEmployees.length === 0 ? (
<div className="px-3 py-4 text-center text-xs text-gray-400"></div>
) : filteredEmployees.map((emp: any) => (
<label
key={emp.id}
className="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 cursor-pointer text-sm"
>
<input
type="checkbox"
checked={selectedIds.includes(emp.id)}
onChange={() => toggleEmployee(emp.id)}
className="rounded"
/>
<span>{emp.name}</span>
<span className="text-gray-400 text-xs">{emp.department || ''}</span>
</label>
))}
</div>
{selectedIds.length > 0 && (
<div className="px-3 py-1.5 border-t border-gray-100 text-xs text-primary">
{selectedIds.length}
</div>
)}
</div>
) : (
<Select value={form.employeeId} onChange={(e) => setForm({ ...form, employeeId: e.target.value })}>
<option value=""></option>
{employees.map((emp: any) => (
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department || ''}</option>
))}
</Select>
)}
</div>
)}
<div>
@@ -258,7 +368,7 @@ function TrainingForm({ employees, record, onSubmit, onClose }: {
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" size="sm" onClick={onClose}></Button>
<Button size="sm" onClick={() => onSubmit(form)} disabled={!form.employeeId || !form.topic}></Button>
<Button size="sm" onClick={handleSubmit} disabled={!canSubmit}></Button>
</div>
</div>
</div>
+70 -12
View File
@@ -1,6 +1,6 @@
import { useState } from "react"
import { useState, useEffect } from "react"
import { useQuery } from "@tanstack/react-query"
import { rosterApi, socialInsuranceApi } from '../../lib/api-services'
import { rosterApi, socialInsuranceApi, employeeApi } from '../../lib/api-services'
import Button from "../../components/ui/Button"
import { Input, Label, Select } from "../../components/ui/Input"
import Modal from "../../components/ui/Modal"
@@ -395,7 +395,7 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
<div className="grid grid-cols-4 gap-3">
<div>
<Label></Label>
<Input type="number" value={form.socialInsBase || employee?.monthlySalary || ''} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} placeholder="默认为月工资" />
<Input type="number" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={employee?.monthlySalary || '默认为月工资'} />
</div>
<div>
<Label></Label>
@@ -403,7 +403,7 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
</div>
<div>
<Label></Label>
<Input type="number" value={form.housingFundBase || employee?.monthlySalary || ''} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} placeholder="默认为月工资" />
<Input type="number" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={employee?.monthlySalary || '默认为月工资'} />
</div>
<div>
<Label></Label>
@@ -510,8 +510,13 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
d.setDate(d.getDate() - 1)
return d.toISOString().slice(0, 10)
})()
const [form, setForm] = useState({
name: '', department: '', hireDate: todayStr, monthlySalary: '',
const [form, setForm] = useState(() => {
try {
const saved = localStorage.getItem('add-employee-draft')
if (saved) return JSON.parse(saved)
} catch {}
return {
name: '', department: '', position: '', hireDate: todayStr, monthlySalary: '',
idCardNumber: '', gender: '男' as '男' | '女', femaleWorkerType: '' as '' | 'CADRE' | 'WORKER', phone: '',
city: '北京', education: '',
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
@@ -519,8 +524,21 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
contractYears: 3, probationMonths: 0, probationSalary: 0,
socialInsBase: '', socialInsStartMonth: '',
housingFundBase: '', housingFundStartMonth: '',
}
})
// 持久化草稿到 localStorage,防止录入数据丢失
useEffect(() => {
try {
const isDirty = !!(form.name || form.department || form.idCardNumber || form.monthlySalary || form.phone)
if (isDirty) {
localStorage.setItem('add-employee-draft', JSON.stringify(form))
} else {
localStorage.removeItem('add-employee-draft')
}
} catch {}
}, [form])
const hireMonth = form.hireDate ? form.hireDate.slice(0, 7) : ''
// 入职日期变更 → 同步合同开始日期 + 重算结束日期
@@ -536,7 +554,8 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
}
}
// 根据身份证号自动计算性别(第17位:奇数=男,偶数=女)
// 根据身份证号自动计算性别(第17位:奇数=男,偶数=女)+ 查重
const [idCardDuplicate, setIdCardDuplicate] = useState<{ exists: boolean; employee?: any } | null>(null)
const handleIdCardChange = (idCard: string) => {
let gender = form.gender
if (idCard.length >= 17) {
@@ -544,6 +563,12 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
if (!isNaN(digit)) gender = digit % 2 === 1 ? '男' : '女'
}
setForm({ ...form, idCardNumber: idCard, gender })
setIdCardDuplicate(null)
if (idCard.length === 18) {
employeeApi.checkIdCard(idCard).then((data: { exists: boolean; employee?: any }) => {
setIdCardDuplicate(data)
}).catch(() => {})
}
}
// 计算合同月数
@@ -610,6 +635,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
const handleSubmit = () => {
const data: any = {
name: form.name, department: form.department,
position: form.position || undefined,
hireDate: new Date(form.hireDate).toISOString(),
monthlySalary: form.monthlySalary, gender: form.gender,
femaleWorkerType: form.gender === '女' && form.femaleWorkerType ? form.femaleWorkerType : undefined,
@@ -642,18 +668,29 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
useUnsavedChanges(isDirty)
return (
<Modal open onClose={onClose} title="添加员工" size="xl">
<Modal open onClose={onClose} title="添加员工" size="xl" closeOnOverlayClick={false}>
<div className="space-y-4">
{error && (
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">
{error.response?.data?.error?.message || '操作失败'}
{error.response?.data?.error?.details?.length > 0
? error.response.data.error.details.map((d: any, i: number) => (
<div key={i}> {d.path}: {d.message}</div>
))
: (error.response?.data?.error?.message || '操作失败')}
</div>
)}
{/* 基本信息 */}
<div className="grid grid-cols-4 gap-4">
<div><Label> *</Label><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="员工姓名" /></div>
<div><Label> *</Label><Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} placeholder="如:技术部" /></div>
<div><Label>/</Label><Input value={form.position} onChange={(e) => setForm({ ...form, position: e.target.value })} placeholder="如:前端工程师" /></div>
<div><Label> *</Label><Input value={form.idCardNumber} onChange={(e) => handleIdCardChange(e.target.value)} placeholder="18位" maxLength={18} /></div>
{idCardDuplicate?.exists && (
<div className="col-span-4 px-3 py-2 rounded-md bg-amber-50 text-amber-700 text-xs flex items-center gap-2">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>{idCardDuplicate.employee?.name}{idCardDuplicate.employee?.department}</span>
</div>
)}
<div><Label></Label><div className="text-sm text-gray-600 py-2">{form.idCardNumber.length >= 17 ? form.gender : '自动识别'}</div></div>
{form.gender === '女' && (
<div><Label></Label><Select value={form.femaleWorkerType} onChange={(e) => setForm({ ...form, femaleWorkerType: e.target.value as '' | 'CADRE' | 'WORKER' })}><option value=""></option><option value="CADRE">/</option><option value="WORKER">/</option></Select></div>
@@ -665,7 +702,28 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
<div><Label></Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></div>
</div>
<div className="grid grid-cols-4 gap-4">
<div><Label></Label><Select value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })}>{cities.map((c) => <option key={c} value={c}>{c}</option>)}</Select></div>
<div><Label></Label><Select value={form.city} onChange={async (e) => {
const city = e.target.value
setForm({ ...form, city })
const salary = Number(form.socialInsBase === '' ? form.monthlySalary : form.socialInsBase) || 0
const hfBase = Number(form.housingFundBase === '' ? form.monthlySalary : form.housingFundBase) || 0
if (salary > 0) {
try {
const res = await socialInsuranceApi.calculate(salary, city)
if (res?.capped || res?.floored) {
setForm((prev: any) => ({ ...prev, socialInsBase: String(res.actualBase) }))
}
} catch {}
}
if (hfBase > 0) {
try {
const res = await socialInsuranceApi.housingCalculate(hfBase, city)
if (res?.capped || res?.floored) {
setForm((prev: any) => ({ ...prev, housingFundBase: String(res.actualBase) }))
}
} catch {}
}
}}>{cities.map((c) => <option key={c} value={c}>{c}</option>)}</Select></div>
<div><Label></Label><Select value={form.education} onChange={(e) => setForm({ ...form, education: e.target.value })}><option value=""></option><option value="博士"></option><option value="硕士"></option><option value="本科"></option><option value="大专"></option><option value="高中"></option><option value="其他"></option></Select></div>
</div>
{/* 社保公积金 */}
@@ -678,7 +736,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
<div className="grid grid-cols-4 gap-4">
<div>
<Label></Label>
<Input type="number" value={form.socialInsBase || form.monthlySalary} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} placeholder="默认为月工资" />
<Input type="number" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={form.monthlySalary || '默认为月工资'} />
</div>
<div>
<Label></Label>
@@ -686,7 +744,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
</div>
<div>
<Label></Label>
<Input type="number" value={form.housingFundBase || form.monthlySalary} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} placeholder="默认为月工资" />
<Input type="number" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={form.monthlySalary || '默认为月工资'} />
</div>
<div>
<Label></Label>