fix: 全面优化安全、性能和代码质量

P0: 环境变量校验/事务保护/RBAC权限
P1: 花名册分页/密码重置验证码/ErrorBoundary/N+1查询优化
P2: 导出限制/自定义错误类/body大小限制/代码去重
P3: 自定义确认弹窗替换window.confirm
This commit is contained in:
freedakgmail
2026-07-24 22:28:58 +08:00
parent 9ec21fedea
commit 5c12f28ac7
23 changed files with 634 additions and 366 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ app.use(
credentials: true,
}),
)
app.use(express.json())
app.use(express.json({ limit: '10mb' }))
app.use(morgan('dev'))
app.get('/health', (_req, res) => {
+3
View File
@@ -1,4 +1,7 @@
import app from './app'
import { validateEnv } from './lib/config'
validateEnv()
const PORT = Number(process.env.PORT) || 3000
+18
View File
@@ -0,0 +1,18 @@
export class AppError extends Error {
constructor(
public code: string,
message: string,
public statusCode: number = 400,
) {
super(message)
this.name = 'AppError'
}
}
export function isAppError(err: unknown): err is AppError {
return err instanceof AppError
}
export function throwAppError(code: string, message: string, statusCode: number = 400): never {
throw new AppError(code, message, statusCode)
}
+22
View File
@@ -0,0 +1,22 @@
const required = ['JWT_SECRET', 'JWT_REFRESH_SECRET', 'ENCRYPTION_KEY']
const defaults: Record<string, string> = {
JWT_SECRET: 'dev-secret',
JWT_REFRESH_SECRET: 'dev-refresh-secret',
ENCRYPTION_KEY: 'default-32-byte-encryption-key!!',
}
export function validateEnv(): void {
const missing: string[] = []
for (const key of required) {
if (!process.env[key] || process.env[key] === defaults[key]) {
missing.push(key)
}
}
if (missing.length > 0 && process.env.NODE_ENV === 'production') {
console.error(`[FATAL] 以下环境变量未设置或使用了默认值,生产环境禁止启动: ${missing.join(', ')}`)
process.exit(1)
}
if (missing.length > 0) {
console.warn(`[WARN] 以下环境变量使用了默认值,仅限开发环境: ${missing.join(', ')}`)
}
}
+26
View File
@@ -1,8 +1,16 @@
import { Request, Response, NextFunction } from 'express'
import { ZodError } from 'zod'
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'
import { AppError } from '../lib/AppError'
export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
success: false,
error: { code: err.code, message: err.message },
})
}
if (err instanceof ZodError) {
return res.status(422).json({
success: false,
@@ -29,6 +37,24 @@ export function errorHandler(err: unknown, _req: Request, res: Response, _next:
}
}
if (typeof err === 'object' && err !== null && 'code' in err && 'message' in err) {
const e = err as { code: string; message: string }
const statusMap: Record<string, number> = {
NOT_FOUND: 404,
CONFLICT: 409,
VALIDATION_ERROR: 422,
PLAN_LIMIT: 403,
AUTH_FAILED: 401,
TOKEN_INVALID: 401,
ACCOUNT_DISABLED: 403,
DUPLICATE: 400,
}
return res.status(statusMap[e.code] || 400).json({
success: false,
error: { code: e.code, message: e.message },
})
}
console.error('Unhandled error:', err)
return res.status(500).json({
success: false,
+16
View File
@@ -0,0 +1,16 @@
import { Response, NextFunction } from 'express'
import { AuthRequest } from './auth'
export function requireRole(...roles: string[]) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: '未认证' } })
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ success: false, error: { code: 'FORBIDDEN', message: '无权限执行此操作' } })
}
next()
}
}
export const requireAdmin = requireRole('ADMIN')
+5
View File
@@ -81,6 +81,11 @@ router.post('/forgot-password/verify', authLimiter, async (req, res, next) => {
router.post('/reset-password', authLimiter, async (req, res, next) => {
try {
const data = resetPasswordSchema.parse(req.body)
const stored = codeStore.get(data.phone)
if (!stored || stored.expiresAt < Date.now()) {
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
}
codeStore.delete(data.phone)
const result = await resetPassword(data.phone, data.newPassword)
res.json({ success: true, data: result })
} catch (err) {
+19 -11
View File
@@ -111,22 +111,30 @@ router.post('/contracts/preview-renew', authMiddleware, async (req: AuthRequest,
}
// 合规检查:按员工分组,检查历史固定期合同次数
const employeeIds = contracts.map((c) => c.employeeId)
const allFixedContracts = await prisma.laborContract.findMany({
where: {
employeeId: { in: employeeIds },
orgId: req.user!.orgId,
contractType: 'FIXED',
},
orderBy: { startDate: 'asc' },
})
const fixedContractsByEmp = new Map<string, typeof allFixedContracts>()
for (const c of allFixedContracts) {
if (!fixedContractsByEmp.has(c.employeeId)) {
fixedContractsByEmp.set(c.employeeId, [])
}
fixedContractsByEmp.get(c.employeeId)!.push(c)
}
const results = []
for (const contract of contracts) {
const employee = contract.employee
// 查找该员工所有历史固定期合同(按时间正序,用于判断续签次数)
const allFixedContracts = await prisma.laborContract.findMany({
where: {
employeeId: contract.employeeId,
orgId: req.user!.orgId,
contractType: 'FIXED',
},
orderBy: { startDate: 'asc' },
})
const empFixedContracts = fixedContractsByEmp.get(contract.employeeId) || []
// 当前合同是第几次固定期(从1开始计数)
const currentIndex = allFixedContracts.findIndex((c) => c.id === contract.id)
const currentIndex = empFixedContracts.findIndex((c) => c.id === contract.id)
const renewalCount = currentIndex + 1
// 判断是否应签无固定期限:
+12 -10
View File
@@ -1,5 +1,6 @@
import { Router, Response } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { requireAdmin } from '../middleware/rbac'
import prisma from '../lib/prisma'
import { decrypt } from '../lib/crypto'
import ExcelJS from 'exceljs'
@@ -21,22 +22,23 @@ function maskBankAccount(account: string | null): string | null {
}
// 导出全部数据(支持模块选择、格式选择、脱敏)
router.get('/all', authMiddleware, async (req: AuthRequest, res: Response, next) => {
router.get('/all', authMiddleware, requireAdmin, async (req: AuthRequest, res: Response, next) => {
try {
const orgId = req.user!.orgId
const format = (req.query.format as string) || 'json'
const mask = req.query.mask === 'true' || req.user!.role !== 'ADMIN'
const modules = (req.query.modules as string || 'employees,contracts,terminations,payrollBatches,payslips,socialRecords,housingRecords,riskItems').split(',')
const fetchMap: Record<string, () => Promise<any>> = {
employees: () => prisma.employee.findMany({ where: { orgId } }),
contracts: () => prisma.laborContract.findMany({ where: { orgId } }),
terminations: () => prisma.terminationRecord.findMany({ where: { orgId } }),
payrollBatches: () => prisma.payrollBatch.findMany({ where: { orgId } }),
payslips: () => prisma.payslip.findMany({ where: { orgId } }),
socialRecords: () => prisma.employeeSocialInsRecord.findMany({ where: { orgId } }),
housingRecords: () => prisma.employeeHousingFundRecord.findMany({ where: { orgId } }),
riskItems: () => prisma.riskItem.findMany({ where: { orgId } }),
const exportBatchSize = 500
const fetchMap: Record<string, () => Promise<any[]>> = {
employees: () => prisma.employee.findMany({ where: { orgId }, take: exportBatchSize }),
contracts: () => prisma.laborContract.findMany({ where: { orgId }, take: exportBatchSize }),
terminations: () => prisma.terminationRecord.findMany({ where: { orgId }, take: exportBatchSize }),
payrollBatches: () => prisma.payrollBatch.findMany({ where: { orgId }, take: exportBatchSize }),
payslips: () => prisma.payslip.findMany({ where: { orgId }, take: exportBatchSize }),
socialRecords: () => prisma.employeeSocialInsRecord.findMany({ where: { orgId }, take: exportBatchSize }),
housingRecords: () => prisma.employeeHousingFundRecord.findMany({ where: { orgId }, take: exportBatchSize }),
riskItems: () => prisma.riskItem.findMany({ where: { orgId }, take: exportBatchSize }),
}
const useGzip = req.query.gzip !== 'false'
+4 -3
View File
@@ -2,6 +2,7 @@ import { Router, Response } from 'express'
import multer from 'multer'
import * as XLSX from 'xlsx'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { requireAdmin } from '../middleware/rbac'
import { encrypt, decrypt, sha256 } from '../lib/crypto'
import prisma from '../lib/prisma'
@@ -85,7 +86,7 @@ function num(v: any): number {
// ========== 导入预览(不写入数据库) ==========
router.post('/excel/preview', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
try {
if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } })
@@ -209,7 +210,7 @@ router.post('/excel/error-log', authMiddleware, async (req: AuthRequest, res: Re
}
})
router.post('/excel', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
try {
if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } })
const orgId = req.user!.orgId
@@ -420,7 +421,7 @@ router.get('/template', authMiddleware, async (_req: AuthRequest, res: Response)
// ========== 月度导入 ==========
router.post('/monthly', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
try {
if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } })
const orgId = req.user!.orgId
+31 -10
View File
@@ -40,13 +40,31 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
]
}
const [total, employees] = await Promise.all([
// 状态过滤在 DB 层完成(contractStatus 需要后处理计算,仍需内存过滤)
if (status === 'RESIGNED') {
whereBase.status = 'RESIGNED'
} else if (status === 'PRE_HIRE') {
whereBase.status = 'ACTIVE'
whereBase.hireDate = { gt: today }
} else if (status === 'ACTIVE') {
whereBase.status = 'ACTIVE'
whereBase.hireDate = { lte: today }
}
// unsigned 合同状态可以在 DB 层过滤
if (contractStatus === 'unsigned') {
whereBase.contracts = { none: {} }
}
// 当有 contractStatus(非 unsigned)筛选时,需要先查全部再过滤后分页
const needPostFilter = !!contractStatus && contractStatus !== 'unsigned'
const [dbTotal, employees] = await Promise.all([
prisma.employee.count({ where: whereBase }),
prisma.employee.findMany({
where: whereBase,
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
...(needPostFilter ? {} : { skip, take: pageSize }),
include: {
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
terminations: { orderBy: { terminationDate: 'desc' }, take: 1 },
@@ -107,22 +125,25 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
}
})
// 前端过滤:状态和合同状态(因为合同状态需要后处理,不适合放 Prisma where
if (status) {
result = result.filter((e) => e.status === status)
}
if (contractStatus) {
// 前端过滤:合同状态(非 unsigned 的需要后处理计算
if (contractStatus && contractStatus !== 'unsigned') {
result = result.filter((e) => e.contractStatus === contractStatus)
}
// 计算过滤后的总数和分页
const filteredTotal = needPostFilter ? result.length : dbTotal
if (needPostFilter) {
result = result.slice(skip, skip + pageSize)
}
res.json({
success: true,
data: result,
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
total: filteredTotal,
totalPages: Math.ceil(filteredTotal / pageSize),
},
})
} catch (err) {
+7 -6
View File
@@ -2,6 +2,7 @@ import { Router } from 'express'
import bcrypt from 'bcryptjs'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { requireAdmin } from '../middleware/rbac'
import { z } from 'zod'
const router = Router()
@@ -35,7 +36,7 @@ router.get('/org', async (req: AuthRequest, res, next) => {
})
// 更新企业信息
router.put('/org', async (req: AuthRequest, res, next) => {
router.put('/org', requireAdmin, async (req: AuthRequest, res, next) => {
try {
const { name, payrollFrequency } = req.body as { name?: string; payrollFrequency?: number }
const updateData: any = {}
@@ -67,7 +68,7 @@ router.get('/users', async (req: AuthRequest, res, next) => {
})
// 添加用户
router.post('/users', async (req: AuthRequest, res, next) => {
router.post('/users', requireAdmin, async (req: AuthRequest, res, next) => {
try {
const data = createUserSchema.parse(req.body)
const existing = await prisma.user.findFirst({ where: { phone: data.phone, orgId: req.user!.orgId } })
@@ -92,7 +93,7 @@ router.post('/users', async (req: AuthRequest, res, next) => {
})
// 更新用户
router.put('/users/:id', async (req: AuthRequest, res, next) => {
router.put('/users/:id', requireAdmin, async (req: AuthRequest, res, next) => {
try {
const data = updateUserSchema.parse(req.body)
const user = await prisma.user.update({
@@ -107,7 +108,7 @@ router.put('/users/:id', async (req: AuthRequest, res, next) => {
})
// 删除用户
router.delete('/users/:id', async (req: AuthRequest, res, next) => {
router.delete('/users/:id', requireAdmin, async (req: AuthRequest, res, next) => {
try {
if (req.params.id === req.user!.id) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '不能删除自己' } })
@@ -120,7 +121,7 @@ router.delete('/users/:id', async (req: AuthRequest, res, next) => {
})
// 禁用/启用用户
router.patch('/users/:id/toggle-disable', async (req: AuthRequest, res, next) => {
router.patch('/users/:id/toggle-disable', requireAdmin, async (req: AuthRequest, res, next) => {
try {
if (req.params.id === req.user!.id) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '不能禁用自己' } })
@@ -141,7 +142,7 @@ router.patch('/users/:id/toggle-disable', async (req: AuthRequest, res, next) =>
})
// 切换套餐
router.put('/plan', async (req: AuthRequest, res, next) => {
router.put('/plan', requireAdmin, async (req: AuthRequest, res, next) => {
try {
const { plan } = req.body as { plan: 'FREE' | 'PRO' | 'ENTERPRISE' }
if (!['FREE', 'PRO', 'ENTERPRISE'].includes(plan)) {
+1
View File
@@ -25,6 +25,7 @@ export const forgotPasswordSchema = z.object({
export const resetPasswordSchema = z.object({
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
code: z.string().length(6, '验证码为6位数字'),
newPassword: z.string().min(8, '密码至少8位').max(32, '密码最多32位'),
})
+101 -101
View File
@@ -190,112 +190,112 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
const socialInsStartMonth = data.socialInsStartMonth || hireMonth
const housingFundStartMonth = data.housingFundStartMonth || hireMonth
const employee = await prisma.employee.create({
data: {
orgId,
name: data.name,
department: data.department,
hireDate,
monthlySalary: encrypt(data.monthlySalary),
gender: data.gender,
phone: data.phone,
idCardNumber: data.idCardNumber ? encrypt(data.idCardNumber) : null,
idCardHash: data.idCardNumber ? sha256(data.idCardNumber) : null,
isPregnant: data.isPregnant || false,
isInMedicalPeriod: data.isInMedicalPeriod || false,
isWorkInjured: data.isWorkInjured || false,
socialInsBase,
housingFundBase,
socialInsStartMonth,
housingFundStartMonth,
createdBy: userId,
city: data.city || '北京',
},
})
// 创建社保缴费记录
await prisma.employeeSocialInsRecord.create({
data: {
orgId,
employeeId: employee.id,
startMonth: socialInsStartMonth,
endMonth: null,
base: socialInsBase,
changeType: 'ONBOARDING',
createdBy: userId,
city: data.city || '北京',
},
})
// 创建公积金缴费记录
await prisma.employeeHousingFundRecord.create({
data: {
orgId,
employeeId: employee.id,
startMonth: housingFundStartMonth,
endMonth: null,
base: housingFundBase,
changeType: 'ONBOARDING',
createdBy: userId,
city: data.city || '北京',
},
})
// 创建初始薪资变更记录
await prisma.salaryChangeRecord.create({
data: {
orgId,
employeeId: employee.id,
oldSalary: 0,
newSalary: salaryNum,
effectiveDate: hireDate,
effectiveMonth: hireMonth,
endMonth: null,
changeType: 'ONBOARDING',
createdBy: userId,
},
})
// 创建初始部门记录
await prisma.employeeDepartmentRecord.create({
data: {
orgId,
employeeId: employee.id,
oldDepartment: '',
newDepartment: data.department,
effectiveMonth: hireMonth,
endMonth: null,
changeType: 'ONBOARDING',
createdBy: userId,
},
})
if (data.contract && data.contract.contractType !== 'UNSIGNED') {
const contractMonths = data.contract.endDate
? Math.ceil(daysBetween(new Date(data.contract.endDate), new Date(data.contract.startDate)) / 30.44)
: data.contract.contractYears * 12
const probationCheck = validateProbation(contractMonths, data.contract.probationMonths)
if (!probationCheck.valid) {
throw { code: 'VALIDATION_ERROR', message: probationCheck.message }
}
await prisma.laborContract.create({
const employee = await prisma.$transaction(async (tx) => {
const emp = await tx.employee.create({
data: {
orgId,
employeeId: employee.id,
signDate: data.contract.signDate ? new Date(data.contract.signDate) : null,
startDate: new Date(data.contract.startDate),
endDate: data.contract.endDate ? new Date(data.contract.endDate) : null,
contractType: data.contract.contractType,
signMethod: data.contract.signMethod || 'PAPER',
contractYears: data.contract.contractYears || 3,
probationMonths: data.contract.probationMonths || 0,
probationSalary: data.contract.probationSalary || 0,
name: data.name,
department: data.department,
hireDate,
monthlySalary: encrypt(data.monthlySalary),
gender: data.gender,
phone: data.phone,
idCardNumber: data.idCardNumber ? encrypt(data.idCardNumber) : null,
idCardHash: data.idCardNumber ? sha256(data.idCardNumber) : null,
isPregnant: data.isPregnant || false,
isInMedicalPeriod: data.isInMedicalPeriod || false,
isWorkInjured: data.isWorkInjured || false,
socialInsBase,
housingFundBase,
socialInsStartMonth,
housingFundStartMonth,
createdBy: userId,
city: data.city || '北京',
},
})
await tx.employeeSocialInsRecord.create({
data: {
orgId,
employeeId: emp.id,
startMonth: socialInsStartMonth,
endMonth: null,
base: socialInsBase,
changeType: 'ONBOARDING',
createdBy: userId,
city: data.city || '北京',
},
})
await tx.employeeHousingFundRecord.create({
data: {
orgId,
employeeId: emp.id,
startMonth: housingFundStartMonth,
endMonth: null,
base: housingFundBase,
changeType: 'ONBOARDING',
createdBy: userId,
city: data.city || '北京',
},
})
await tx.salaryChangeRecord.create({
data: {
orgId,
employeeId: emp.id,
oldSalary: 0,
newSalary: salaryNum,
effectiveDate: hireDate,
effectiveMonth: hireMonth,
endMonth: null,
changeType: 'ONBOARDING',
createdBy: userId,
},
})
}
await tx.employeeDepartmentRecord.create({
data: {
orgId,
employeeId: emp.id,
oldDepartment: '',
newDepartment: data.department,
effectiveMonth: hireMonth,
endMonth: null,
changeType: 'ONBOARDING',
createdBy: userId,
},
})
if (data.contract && data.contract.contractType !== 'UNSIGNED') {
const contractMonths = data.contract.endDate
? Math.ceil(daysBetween(new Date(data.contract.endDate), new Date(data.contract.startDate)) / 30.44)
: data.contract.contractYears * 12
const probationCheck = validateProbation(contractMonths, data.contract.probationMonths)
if (!probationCheck.valid) {
throw { code: 'VALIDATION_ERROR', message: probationCheck.message }
}
await tx.laborContract.create({
data: {
orgId,
employeeId: emp.id,
signDate: data.contract.signDate ? new Date(data.contract.signDate) : null,
startDate: new Date(data.contract.startDate),
endDate: data.contract.endDate ? new Date(data.contract.endDate) : null,
contractType: data.contract.contractType,
signMethod: data.contract.signMethod || 'PAPER',
contractYears: data.contract.contractYears || 3,
probationMonths: data.contract.probationMonths || 0,
probationSalary: data.contract.probationSalary || 0,
createdBy: userId,
},
})
}
return emp
})
await runRiskDetection(orgId)
+20 -9
View File
@@ -280,18 +280,29 @@ export async function generatePayslipFromBatches(orgId: string, month: string) {
// 计算累计数据
const year = month.slice(0, 4)
const employeeIds = Array.from(employeeMap.keys())
const allPrevPayslips = await prisma.payslip.findMany({
where: { orgId, employeeId: { in: employeeIds }, month: { startsWith: year, lt: month } },
select: { employeeId: true, totalPay: true, tax: true, socialEmp: true, housingEmp: true },
})
const prevMap = new Map<string, { totalPay: number; tax: number; socialEmp: number; housingEmp: number }>()
for (const p of allPrevPayslips) {
const existing = prevMap.get(p.employeeId) || { totalPay: 0, tax: 0, socialEmp: 0, housingEmp: 0 }
existing.totalPay += p.totalPay
existing.tax += p.tax
existing.socialEmp += p.socialEmp
existing.housingEmp += p.housingEmp
prevMap.set(p.employeeId, existing)
}
let generated = 0
for (const [employeeId, summary] of employeeMap) {
// 获取当年之前月份的累计数据
const prevPayslips = await prisma.payslip.findMany({
where: { orgId, employeeId, month: { startsWith: year, lt: month } },
select: { totalPay: true, tax: true, socialEmp: true, housingEmp: true },
})
const ytdIncome = prevPayslips.reduce((s, p) => s + p.totalPay, 0) + summary.totalPay
const ytdTaxDeducted = prevPayslips.reduce((s, p) => s + p.tax, 0) + summary.tax
const ytdSocialEmp = prevPayslips.reduce((s, p) => s + p.socialEmp, 0) + summary.socialEmp
const ytdHousingEmp = prevPayslips.reduce((s, p) => s + p.housingEmp, 0) + summary.housingEmp
const prev = prevMap.get(employeeId) || { totalPay: 0, tax: 0, socialEmp: 0, housingEmp: 0 }
const ytdIncome = prev.totalPay + summary.totalPay
const ytdTaxDeducted = prev.tax + summary.tax
const ytdSocialEmp = prev.socialEmp + summary.socialEmp
const ytdHousingEmp = prev.housingEmp + summary.housingEmp
await prisma.payslip.upsert({
where: { employeeId_month: { employeeId, month } },
+183 -200
View File
@@ -136,150 +136,125 @@ export function assessRisk(employee: any, reason: string): { level: RiskAssessme
return { level, warnings }
}
async function createTerminationRecord(
orgId: string,
userId: string,
data: any,
recordData: {
type: 'TERMINATION' | 'RESIGNATION'
reason: TerminationReason | 'RESIGNATION'
compensation?: number
riskLevel: RiskAssessment
checklist: any
remark?: string | null
resignationReason?: string | null
},
conflictMsg: string,
) {
const employee = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } })
if (!employee) {
throw { code: 'NOT_FOUND', message: '员工不存在' }
}
const latestTerm = await prisma.terminationRecord.findFirst({
where: { employeeId: data.employeeId },
orderBy: { terminationDate: 'desc' },
})
if (latestTerm && latestTerm.terminationDate >= employee.hireDate) {
throw { code: 'CONFLICT', message: conflictMsg }
}
const termDate = new Date(data.terminationDate)
const termMonth = dateToMonth(termDate)
const socialInsEndMonth = data.socialInsEndMonth || termMonth
const housingFundEndMonth = data.housingFundEndMonth || termMonth
const today = new Date()
today.setHours(0, 0, 0, 0)
const isResigned = termDate <= today
return await prisma.$transaction(async (tx) => {
const record = await tx.terminationRecord.create({
data: {
orgId,
employeeId: data.employeeId,
terminationDate: termDate,
socialInsEndMonth,
housingFundEndMonth,
createdBy: userId,
...recordData,
},
})
await tx.employeeSocialInsRecord.updateMany({
where: { employeeId: data.employeeId, endMonth: null },
data: { endMonth: socialInsEndMonth, changeRefId: record.id },
})
await tx.employeeHousingFundRecord.updateMany({
where: { employeeId: data.employeeId, endMonth: null },
data: { endMonth: housingFundEndMonth, changeRefId: record.id },
})
await tx.employee.update({
where: { id: data.employeeId },
data: {
status: isResigned ? 'RESIGNED' : 'ACTIVE',
socialInsEndMonth,
housingFundEndMonth,
},
})
await tx.riskItem.updateMany({
where: { employeeId: data.employeeId, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedAt: new Date() },
})
return { id: record.id }
})
}
export async function createTermination(orgId: string, userId: string, data: any) {
const employee = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } })
if (!employee) {
throw { code: 'NOT_FOUND', message: '员工不存在' }
}
// 校验:已有离职/解聘记录且未重新雇佣则不允许再次解聘
const latestTerm = await prisma.terminationRecord.findFirst({
where: { employeeId: data.employeeId },
orderBy: { terminationDate: 'desc' },
})
if (latestTerm && latestTerm.terminationDate >= employee.hireDate) {
throw { code: 'CONFLICT', message: '该员工已有离职/解聘记录,如需再次解聘请先办理重新雇佣' }
}
const { level } = assessRisk(employee, data.reason)
const termDate = new Date(data.terminationDate)
const termMonth = dateToMonth(termDate)
const socialInsEndMonth = data.socialInsEndMonth || termMonth
const housingFundEndMonth = data.housingFundEndMonth || termMonth
const record = await prisma.terminationRecord.create({
data: {
orgId,
employeeId: data.employeeId,
return createTerminationRecord(
orgId,
userId,
data,
{
type: 'TERMINATION',
reason: data.reason,
terminationDate: termDate,
compensation: data.compensation || 0,
socialInsEndMonth,
housingFundEndMonth,
riskLevel: level,
checklist: data.checklist || {},
remark: data.remark,
createdBy: userId,
},
})
// 关闭社保缴费记录(设置 endMonth)
await prisma.employeeSocialInsRecord.updateMany({
where: { employeeId: data.employeeId, endMonth: null },
data: { endMonth: socialInsEndMonth, changeRefId: record.id },
})
// 关闭公积金缴费记录
await prisma.employeeHousingFundRecord.updateMany({
where: { employeeId: data.employeeId, endMonth: null },
data: { endMonth: housingFundEndMonth, changeRefId: record.id },
})
// 根据解聘日期判断在职/离职状态
const today = new Date()
today.setHours(0, 0, 0, 0)
const isResigned = termDate <= today
await prisma.employee.update({
where: { id: data.employeeId },
data: {
status: isResigned ? 'RESIGNED' : 'ACTIVE',
socialInsEndMonth,
housingFundEndMonth,
},
})
await prisma.riskItem.updateMany({
where: { employeeId: data.employeeId, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedAt: new Date() },
})
return { id: record.id }
'该员工已有离职/解聘记录,如需再次解聘请先办理重新雇佣',
)
}
// 员工主动离职
export async function createResignation(orgId: string, userId: string, data: any) {
const employee = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } })
if (!employee) {
throw { code: 'NOT_FOUND', message: '员工不存在' }
}
// 校验:已有离职/解聘记录且未重新雇佣则不允许再次离职
const latestTerm = await prisma.terminationRecord.findFirst({
where: { employeeId: data.employeeId },
orderBy: { terminationDate: 'desc' },
})
if (latestTerm && latestTerm.terminationDate >= employee.hireDate) {
throw { code: 'CONFLICT', message: '该员工已有离职/解聘记录,如需再次办理请先重新雇佣' }
}
const termDate = new Date(data.terminationDate)
const termMonth = dateToMonth(termDate)
const socialInsEndMonth = data.socialInsEndMonth || termMonth
const housingFundEndMonth = data.housingFundEndMonth || termMonth
const record = await prisma.terminationRecord.create({
data: {
orgId,
employeeId: data.employeeId,
return createTerminationRecord(
orgId,
userId,
data,
{
type: 'RESIGNATION',
reason: 'RESIGNATION',
terminationDate: termDate,
resignationReason: data.resignationReason || null,
compensation: 0,
socialInsEndMonth,
housingFundEndMonth,
riskLevel: 'SAFE',
checklist: {},
remark: data.remark || null,
createdBy: userId,
},
})
// 关闭社保缴费记录
await prisma.employeeSocialInsRecord.updateMany({
where: { employeeId: data.employeeId, endMonth: null },
data: { endMonth: socialInsEndMonth, changeRefId: record.id },
})
// 关闭公积金缴费记录
await prisma.employeeHousingFundRecord.updateMany({
where: { employeeId: data.employeeId, endMonth: null },
data: { endMonth: housingFundEndMonth, changeRefId: record.id },
})
// 根据离职日期判断在职/离职状态
const today = new Date()
today.setHours(0, 0, 0, 0)
const isResigned = termDate <= today
await prisma.employee.update({
where: { id: data.employeeId },
data: {
status: isResigned ? 'RESIGNED' : 'ACTIVE',
socialInsEndMonth,
housingFundEndMonth,
},
})
await prisma.riskItem.updateMany({
where: { employeeId: data.employeeId, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedAt: new Date() },
})
return { id: record.id }
'该员工已有离职/解聘记录,如需再次办理请先重新雇佣',
)
}
// 撤回离职/解聘(仅未到日期可撤回)
@@ -439,73 +414,84 @@ export async function batchTerminate(
const success: string[] = []
const failed: Array<{ employeeId: string; reason: string }> = []
const employeeIds = items.map((i) => i.employeeId)
const employees = await prisma.employee.findMany({
where: { id: { in: employeeIds }, orgId },
})
const empMap = new Map(employees.map((e) => [e.id, e]))
const existingTerms = await prisma.terminationRecord.findMany({
where: { employeeId: { in: employeeIds } },
orderBy: { terminationDate: 'desc' },
})
const latestTermMap = new Map<string, Date>()
for (const t of existingTerms) {
if (!latestTermMap.has(t.employeeId)) {
latestTermMap.set(t.employeeId, t.terminationDate)
}
}
const today = new Date()
today.setHours(0, 0, 0, 0)
for (const item of items) {
try {
const termDate = new Date(item.terminationDate)
const termMonth = dateToMonth(termDate)
// 校验:已有离职/解聘记录
const latestTerm = await prisma.terminationRecord.findFirst({
where: { employeeId: item.employeeId },
orderBy: { terminationDate: 'desc' },
})
const employee = await prisma.employee.findFirst({ where: { id: item.employeeId, orgId } })
const employee = empMap.get(item.employeeId)
if (!employee) {
failed.push({ employeeId: item.employeeId, reason: '员工不存在' })
continue
}
if (latestTerm && latestTerm.terminationDate >= employee.hireDate) {
const latestTermDate = latestTermMap.get(item.employeeId)
if (latestTermDate && latestTermDate >= employee.hireDate) {
failed.push({ employeeId: item.employeeId, reason: '该员工已有离职/解聘记录' })
continue
}
const termDate = new Date(item.terminationDate)
const termMonth = dateToMonth(termDate)
const { level } = assessRisk(employee, item.reason)
await prisma.terminationRecord.create({
data: {
orgId,
employeeId: item.employeeId,
type: 'TERMINATION',
reason: item.reason as TerminationReason,
terminationDate: termDate,
compensation: item.compensation || 0,
socialInsEndMonth: termMonth,
housingFundEndMonth: termMonth,
riskLevel: level,
checklist: {},
remark: '批量解聘',
createdBy: userId,
},
})
// 关闭社保和公积金
await prisma.employeeSocialInsRecord.updateMany({
where: { employeeId: item.employeeId, endMonth: null },
data: { endMonth: termMonth },
})
await prisma.employeeHousingFundRecord.updateMany({
where: { employeeId: item.employeeId, endMonth: null },
data: { endMonth: termMonth },
})
// 更新员工状态
const today = new Date()
today.setHours(0, 0, 0, 0)
const isResigned = termDate <= today
await prisma.employee.update({
where: { id: item.employeeId },
data: {
status: isResigned ? 'RESIGNED' : 'ACTIVE',
socialInsEndMonth: termMonth,
housingFundEndMonth: termMonth,
},
})
await prisma.$transaction(async (tx) => {
await tx.terminationRecord.create({
data: {
orgId,
employeeId: item.employeeId,
type: 'TERMINATION',
reason: item.reason as TerminationReason,
terminationDate: termDate,
compensation: item.compensation || 0,
socialInsEndMonth: termMonth,
housingFundEndMonth: termMonth,
riskLevel: level,
checklist: {},
remark: '批量解聘',
createdBy: userId,
},
})
// 关闭风险项
await prisma.riskItem.updateMany({
where: { employeeId: item.employeeId, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedAt: new Date() },
await tx.employeeSocialInsRecord.updateMany({
where: { employeeId: item.employeeId, endMonth: null },
data: { endMonth: termMonth },
})
await tx.employeeHousingFundRecord.updateMany({
where: { employeeId: item.employeeId, endMonth: null },
data: { endMonth: termMonth },
})
await tx.employee.update({
where: { id: item.employeeId },
data: {
status: isResigned ? 'RESIGNED' : 'ACTIVE',
socialInsEndMonth: termMonth,
housingFundEndMonth: termMonth,
},
})
await tx.riskItem.updateMany({
where: { employeeId: item.employeeId, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedAt: new Date() },
})
})
success.push(item.employeeId)
@@ -687,42 +673,39 @@ export async function executeTermination(orgId: string, recordId: string, userId
const socialInsEndMonth = record.socialInsEndMonth || termMonth
const housingFundEndMonth = record.housingFundEndMonth || termMonth
// 关闭社保缴费记录
await prisma.employeeSocialInsRecord.updateMany({
where: { employeeId: record.employeeId, endMonth: null },
data: { endMonth: socialInsEndMonth, changeRefId: record.id },
})
// 关闭公积金缴费记录
await prisma.employeeHousingFundRecord.updateMany({
where: { employeeId: record.employeeId, endMonth: null },
data: { endMonth: housingFundEndMonth, changeRefId: record.id },
})
// 更新员工状态
const today = new Date()
today.setHours(0, 0, 0, 0)
const isResigned = termDate <= today
await prisma.employee.update({
where: { id: record.employeeId },
data: {
status: isResigned ? 'RESIGNED' : 'ACTIVE',
socialInsEndMonth,
housingFundEndMonth,
},
})
await prisma.$transaction(async (tx) => {
await tx.employeeSocialInsRecord.updateMany({
where: { employeeId: record.employeeId, endMonth: null },
data: { endMonth: socialInsEndMonth, changeRefId: record.id },
})
// 关闭风险项
await prisma.riskItem.updateMany({
where: { employeeId: record.employeeId, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedAt: new Date() },
})
await tx.employeeHousingFundRecord.updateMany({
where: { employeeId: record.employeeId, endMonth: null },
data: { endMonth: housingFundEndMonth, changeRefId: record.id },
})
// 标记为已完成
await prisma.terminationRecord.update({
where: { id: recordId },
data: { status: 'COMPLETED', updatedBy: userId },
await tx.employee.update({
where: { id: record.employeeId },
data: {
status: isResigned ? 'RESIGNED' : 'ACTIVE',
socialInsEndMonth,
housingFundEndMonth,
},
})
await tx.riskItem.updateMany({
where: { employeeId: record.employeeId, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedAt: new Date() },
})
await tx.terminationRecord.update({
where: { id: recordId },
data: { status: 'COMPLETED', updatedBy: userId },
})
})
return { id: recordId }
+59
View File
@@ -0,0 +1,59 @@
import { Component, ErrorInfo, ReactNode } from 'react'
interface Props {
children: ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
export default class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught:', error, errorInfo)
}
handleReset = () => {
this.setState({ hasError: false, error: null })
}
render() {
if (this.state.hasError) {
return (
<div className="min-h-[60vh] flex flex-col items-center justify-center gap-4 px-4">
<div className="text-6xl">😵</div>
<h2 className="text-xl font-semibold text-gray-800"></h2>
<p className="text-sm text-gray-500 text-center max-w-md">
{this.state.error?.message || '发生了未知错误,请刷新页面重试'}
</p>
<div className="flex gap-3">
<button
onClick={this.handleReset}
className="px-4 py-2 text-sm font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
</button>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
</button>
</div>
</div>
)
}
return this.props.children
}
}
+66
View File
@@ -0,0 +1,66 @@
import { useState, useCallback, createContext, useContext, ReactNode } from 'react'
import ConfirmDialog from '../components/ui/ConfirmDialog'
interface ConfirmOptions {
title?: string
message: string
confirmLabel?: string
variant?: 'danger' | 'primary'
}
interface ConfirmContextValue {
confirm: (options: ConfirmOptions) => Promise<boolean>
}
const ConfirmContext = createContext<ConfirmContextValue | null>(null)
export function useConfirm(): (options: ConfirmOptions) => Promise<boolean> {
const ctx = useContext(ConfirmContext)
if (!ctx) {
throw new Error('useConfirm must be used within ConfirmProvider')
}
return ctx.confirm
}
export function useConfirmDialog() {
return useConfirm()
}
export function ConfirmProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<{
open: boolean
options: ConfirmOptions
resolve?: (value: boolean) => void
}>({ open: false, options: { message: '' } })
const confirm = useCallback((options: ConfirmOptions) => {
return new Promise<boolean>((resolve) => {
setState({ open: true, options, resolve })
})
}, [])
const handleConfirm = useCallback(() => {
state.resolve?.(true)
setState((s) => ({ ...s, open: false, resolve: undefined }))
}, [state])
const handleCancel = useCallback(() => {
state.resolve?.(false)
setState((s) => ({ ...s, open: false, resolve: undefined }))
}, [state])
return (
<ConfirmContext.Provider value={{ confirm }}>
{children}
<ConfirmDialog
open={state.open}
title={state.options.title || '确认操作'}
message={state.options.message}
confirmLabel={state.options.confirmLabel || '确认'}
variant={state.options.variant || 'danger'}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
</ConfirmContext.Provider>
)
}
+7 -1
View File
@@ -3,6 +3,8 @@ import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import ErrorBoundary from './components/ErrorBoundary'
import { ConfirmProvider } from './hooks/useConfirm'
import './index.css'
const queryClient = new QueryClient({
@@ -19,7 +21,11 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<QueryClientProvider client={queryClient}>
<App />
<ErrorBoundary>
<ConfirmProvider>
<App />
</ConfirmProvider>
</ErrorBoundary>
</QueryClientProvider>
</BrowserRouter>
</React.StrictMode>,
+15 -10
View File
@@ -1,6 +1,7 @@
import { useState, useRef } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { Calculator, AlertCircle, Info, Check, Upload, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText, X, ChevronLeft, Wallet, LayoutTemplate, Clock, Receipt, Users, TrendingDown, TrendingUp, BadgeCheck } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
@@ -62,6 +63,7 @@ export default function Money() {
function BatchManager() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [monthFrom, setMonthFrom] = useState('')
const [monthTo, setMonthTo] = useState('')
@@ -336,8 +338,8 @@ function BatchManager() {
<SettingsIcon className="w-3.5 h-3.5" />
</button>
<button
onClick={() => {
if (window.confirm(`确认删除批次「${batch.name}」?此操作不可撤销。`)) {
onClick={async () => {
if (await confirm({ title: '删除批次', message: `确认删除批次「${batch.name}」?此操作不可撤销。` })) {
deleteBatchMutation.mutate(batch.id)
}
}}
@@ -366,6 +368,7 @@ function BatchManager() {
function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void }) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [editCell, setEditCell] = useState<{ employeeId: string; field: string } | null>(null)
const [editValue, setEditValue] = useState<string>('')
const [page, setPage] = useState(1)
@@ -554,8 +557,8 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
</Button>
<Button
size="sm"
onClick={() => {
if (window.confirm('确认归档?归档后批次将锁定不可编辑。工资条需在「工资条管理」中单独生成。')) {
onClick={async () => {
if (await confirm({ title: '归档确认', message: '确认归档?归档后批次将锁定不可编辑。工资条需在「工资条管理」中单独生成。', variant: 'primary' })) {
archiveMutation.mutate()
}
}}
@@ -567,8 +570,8 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
<Button
variant="danger"
size="sm"
onClick={() => {
if (window.confirm(`确认删除批次「${batch.name}」?此操作不可撤销。`)) {
onClick={async () => {
if (await confirm({ title: '删除批次', message: `确认删除批次「${batch.name}」?此操作不可撤销。` })) {
deleteBatchMutation.mutate()
}
}}
@@ -772,6 +775,7 @@ function AddEmployeeToBatch({ batchId, onClose }: { batchId: string; onClose: ()
function TemplateManager() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [showForm, setShowForm] = useState(false)
const [editingItem, setEditingItem] = useState<any>(null)
const [form, setForm] = useState({
@@ -917,8 +921,8 @@ function TemplateManager() {
</button>
{!item.isDefault && (
<button
onClick={() => {
if (window.confirm(`确认删除薪酬项「${item.name}」?`)) {
onClick={async () => {
if (await confirm({ title: '删除薪酬项', message: `确认删除薪酬项「${item.name}」?` })) {
deleteMutation.mutate(item.id)
}
}}
@@ -1447,6 +1451,7 @@ function OvertimeCalculator() {
function PayslipManager() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
@@ -1517,8 +1522,8 @@ function PayslipManager() {
</Button>
<Button
onClick={() => {
if (window.confirm(`确认从 ${month} 已归档批次汇总生成工资条?这将覆盖已有的工资条数据。`)) {
onClick={async () => {
if (await confirm({ title: '生成工资条', message: `确认从 ${month} 已归档批次汇总生成工资条?这将覆盖已有的工资条数据。`, variant: 'primary' })) {
generateFromBatchMutation.mutate({ month })
}
}}
+7 -2
View File
@@ -1,6 +1,7 @@
import { useState, useRef } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw } from 'lucide-react'
import { QRCodeSVG } from 'qrcode.react'
import api from '../lib/api'
@@ -27,6 +28,7 @@ type DetailTab = 'basic' | 'contract' | 'payslip' | 'overtime' | 'disciplinary'
export default function Roster() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [selectedId, setSelectedId] = useState<string | null>(null)
const [search, setSearch] = useState('')
const debouncedSearch = useDebouncedValue(search, 300)
@@ -476,9 +478,12 @@ export default function Roster() {
title="撤回"
aria-label={`撤回${e.name}${e.latestTerminationType === 'RESIGNATION' ? '离职' : '解聘'}记录`}
className="rounded-md p-1.5 text-gray-400 transition hover:bg-danger/10 hover:text-danger"
onClick={(ev) => {
onClick={async (ev) => {
ev.stopPropagation()
if (e.latestTerminationId && window.confirm(`确认撤回${e.latestTerminationType === 'RESIGNATION' ? '离职' : '解聘'}记录?`)) {
if (e.latestTerminationId && await confirm({
title: '撤回确认',
message: `确认撤回${e.latestTerminationType === 'RESIGNATION' ? '离职' : '解聘'}记录?`,
})) {
revokeMutation.mutate(e.latestTerminationId)
}
}}
+4 -2
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
@@ -12,6 +13,7 @@ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDig
export default function SocialInsurance() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [tab, setTab] = useState<'social' | 'housing' | 'monthly'>('social')
const [city, setCity] = useState<string>('北京')
const [base, setBase] = useState(8000)
@@ -303,8 +305,8 @@ export default function SocialInsurance() {
<Button
variant="secondary"
size="sm"
onClick={() => {
if (window.confirm(`确定要重置${isHousing ? '公积金' : '社保'}基数调整吗?重置后可重新调整。`)) {
onClick={async () => {
if (await confirm({ title: '重置确认', message: `确定要重置${isHousing ? '公积金' : '社保'}基数调整吗?重置后可重新调整。`, variant: 'primary' })) {
isHousing ? resetHousingAdjustMutation.mutate() : resetAdjustMutation.mutate()
}
}}
+7
View File
@@ -19,6 +19,7 @@ const REASONS = [
{ value: 'LAYOFF', label: '公司裁员(经营困难/技术调整等)', legalBasis: '《劳动合同法》第41条' },
{ value: 'EXPIRED', label: '合同到期不续签', legalBasis: '《劳动合同法》第44条、第46条' },
{ value: 'ILLEGAL', label: '违法解除(赔偿金×2', legalBasis: '《劳动合同法》第87条' },
{ value: 'RESIGNATION', label: '员工主动离职', legalBasis: '《劳动合同法》第37条' },
]
const STEPS = ['选择员工', '解聘方式', '合规检查', '费用结算', '工作交接', '确认提交']
@@ -667,6 +668,7 @@ export default function Termination() {
onClick={() => handleEditDraft(item)}
className="p-1 text-gray-500 hover:text-primary"
aria-label="编辑"
title="编辑"
>
<Edit className="w-3.5 h-3.5" />
</button>
@@ -677,6 +679,7 @@ export default function Termination() {
onClick={() => { setDraftId(item.id); setView('detail') }}
className="p-1 text-safe hover:opacity-70"
aria-label="审批通过"
title="审批通过"
>
<CheckCircle className="w-3.5 h-3.5" />
</button>
@@ -684,6 +687,7 @@ export default function Termination() {
onClick={() => { setDraftId(item.id); setView('detail') }}
className="p-1 text-danger hover:opacity-70"
aria-label="驳回"
title="驳回"
>
<XCircle className="w-3.5 h-3.5" />
</button>
@@ -694,6 +698,7 @@ export default function Termination() {
onClick={() => { setDraftId(item.id); executeMutation.mutate() }}
className="p-1 text-primary hover:opacity-70"
aria-label="执行"
title="执行解聘"
>
<Play className="w-3.5 h-3.5" />
</button>
@@ -703,6 +708,7 @@ export default function Termination() {
onClick={() => { setDraftId(item.id); cancelMutation.mutate() }}
className="p-1 text-gray-400 hover:text-danger"
aria-label="撤销"
title="撤销"
>
<Ban className="w-3.5 h-3.5" />
</button>
@@ -711,6 +717,7 @@ export default function Termination() {
onClick={() => handleViewDetail(item.id)}
className="p-1 text-gray-500 hover:text-primary"
aria-label="详情"
title="详情"
>
<FileText className="w-3.5 h-3.5" />
</button>