feat: 绩效系数配置化 + 工作台社保公积金评分修复
- Organization 新增 defaultGradeCoefficients 字段,系统设置可配置缺省绩效系数 - 绩效记录编辑时自动带出系数(优先模板 gradeRules,回退组织缺省配置) - 薪资计算优先用记录 coefficient,回退模板/组织缺省/硬编码默认值 - 绩效模板表单新增等级系数设置区域 - 修复工作台社保公积金评分为0:查询从旧表 SocialInsuranceConfig 改为 SocialYearStandard - 修复 employeesNoSocial 计算逻辑:只统计有正式劳动合同(FIXED/UNFIXED)但无社保记录的员工 - 社保账户未设置 paymentDay 时回退到 Organization.socialInsCutoffDay 生成月度任务 - 合规健康度各维度补充 todoCount 字段 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -141,6 +141,7 @@ model Organization {
|
|||||||
esignPerformanceEnabled Boolean @default(false) // 绩效考核电子签
|
esignPerformanceEnabled Boolean @default(false) // 绩效考核电子签
|
||||||
esignDisciplinaryEnabled Boolean @default(false) // 违纪记录电子签
|
esignDisciplinaryEnabled Boolean @default(false) // 违纪记录电子签
|
||||||
socialInsCutoffDay Int @default(15) // 社保/公积金截止日:每月该日前离职→截止月=离职月-1,该日后→截止月=离职月
|
socialInsCutoffDay Int @default(15) // 社保/公积金截止日:每月该日前离职→截止月=离职月-1,该日后→截止月=离职月
|
||||||
|
defaultGradeCoefficients Json? @default("{\"A\":1.2,\"B\":1.0,\"C\":0.8,\"D\":0.6}") // 缺省绩效系数(不选模板时使用)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -801,6 +802,7 @@ model PerformanceRecord {
|
|||||||
periodType String @default("MONTHLY") // MONTHLY/QUARTERLY/YEARLY
|
periodType String @default("MONTHLY") // MONTHLY/QUARTERLY/YEARLY
|
||||||
score Float @default(0) // 考核得分
|
score Float @default(0) // 考核得分
|
||||||
grade String @default("B") // A/B/C/D
|
grade String @default("B") // A/B/C/D
|
||||||
|
coefficient Float @default(1.0) // 绩效系数(A=1.2, B=1.0, C=0.8, D=0.6,可手动调整)
|
||||||
result String @default("QUALIFIED") // EXCELLENT/QUALIFIED/NEED_IMPROVE/UNQUALIFIED
|
result String @default("QUALIFIED") // EXCELLENT/QUALIFIED/NEED_IMPROVE/UNQUALIFIED
|
||||||
summary String? // 考核评语
|
summary String? // 考核评语
|
||||||
improvementPlan String? // 改进计划(不胜任时)
|
improvementPlan String? // 改进计划(不胜任时)
|
||||||
|
|||||||
@@ -324,13 +324,17 @@ router.get('/batches/:id', async (req: AuthRequest, res: Response, next: NextFun
|
|||||||
// 查询批次月份的绩效记录,附加到 entry 上
|
// 查询批次月份的绩效记录,附加到 entry 上
|
||||||
const perfRecords = await prisma.performanceRecord.findMany({
|
const perfRecords = await prisma.performanceRecord.findMany({
|
||||||
where: { orgId: req.user!.orgId, period: rawBatch.month, periodType: 'MONTHLY' },
|
where: { orgId: req.user!.orgId, period: rawBatch.month, periodType: 'MONTHLY' },
|
||||||
select: { employeeId: true, grade: true, score: true },
|
select: { employeeId: true, grade: true, score: true, coefficient: true },
|
||||||
})
|
})
|
||||||
const gradeCoefficients: Record<string, number> = { A: 1.2, B: 1.0, C: 0.8, D: 0.6 }
|
// 等级默认系数:优先从组织缺省配置取,回退到硬编码
|
||||||
|
const orgForCoeff = await prisma.organization.findUnique({ where: { id: req.user!.orgId }, select: { defaultGradeCoefficients: true } })
|
||||||
|
const DEFAULT_GRADE_COEFFICIENTS: Record<string, number> = (orgForCoeff?.defaultGradeCoefficients as Record<string, number>) || { A: 1.2, B: 1.0, C: 0.8, D: 0.6 }
|
||||||
const perfMap = new Map<string, { grade: string; coefficient: number }>()
|
const perfMap = new Map<string, { grade: string; coefficient: number }>()
|
||||||
for (const r of perfRecords) {
|
for (const r of perfRecords) {
|
||||||
if (!perfMap.has(r.employeeId)) {
|
if (!perfMap.has(r.employeeId)) {
|
||||||
perfMap.set(r.employeeId, { grade: r.grade, coefficient: gradeCoefficients[r.grade] || 1.0 })
|
// 优先用记录中保存的 coefficient,回退到等级默认值
|
||||||
|
const coeff = r.coefficient != null ? r.coefficient : (DEFAULT_GRADE_COEFFICIENTS[r.grade] || 1.0)
|
||||||
|
perfMap.set(r.employeeId, { grade: r.grade, coefficient: coeff })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -916,13 +920,29 @@ router.post('/batches/:batchId/fetch-performance', async (req: AuthRequest, res:
|
|||||||
// 查询批次月份的绩效记录(按 period = YYYY-MM 匹配)
|
// 查询批次月份的绩效记录(按 period = YYYY-MM 匹配)
|
||||||
const performanceRecords = await prisma.performanceRecord.findMany({
|
const performanceRecords = await prisma.performanceRecord.findMany({
|
||||||
where: { orgId, period: batch.month, periodType: 'MONTHLY' },
|
where: { orgId, period: batch.month, periodType: 'MONTHLY' },
|
||||||
select: { employeeId: true, grade: true, score: true, result: true },
|
select: { employeeId: true, grade: true, score: true, result: true, coefficient: true, templateId: true },
|
||||||
})
|
})
|
||||||
const perfMap = new Map<string, { grade: string; score: number }>()
|
// 查询关联的绩效模板(用于回退取 gradeRules 中的 coefficient)
|
||||||
|
const templateIds = [...new Set(performanceRecords.map(r => r.templateId).filter(Boolean))] as string[]
|
||||||
|
const templates = templateIds.length > 0 ? await prisma.performanceTemplate.findMany({
|
||||||
|
where: { id: { in: templateIds } },
|
||||||
|
select: { id: true, gradeRules: true },
|
||||||
|
}) : []
|
||||||
|
const templateCoeffMap = new Map<string, Record<string, number>>()
|
||||||
|
for (const t of templates) {
|
||||||
|
const coeffs: Record<string, number> = {}
|
||||||
|
if (t.gradeRules && Array.isArray(t.gradeRules)) {
|
||||||
|
for (const rule of t.gradeRules as any[]) {
|
||||||
|
if (rule.grade && rule.coefficient != null) coeffs[rule.grade] = Number(rule.coefficient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templateCoeffMap.set(t.id, coeffs)
|
||||||
|
}
|
||||||
|
const perfMap = new Map<string, { grade: string; score: number; coefficient: number | null }>()
|
||||||
for (const r of performanceRecords) {
|
for (const r of performanceRecords) {
|
||||||
// 同一员工同月可能有多条记录,取最新一条(按创建时间倒序已由 DB 保证)
|
// 同一员工同月可能有多条记录,取最新一条(按创建时间倒序已由 DB 保证)
|
||||||
if (!perfMap.has(r.employeeId)) {
|
if (!perfMap.has(r.employeeId)) {
|
||||||
perfMap.set(r.employeeId, { grade: r.grade, score: r.score })
|
perfMap.set(r.employeeId, { grade: r.grade, score: r.score, coefficient: r.coefficient })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -957,8 +977,9 @@ router.post('/batches/:batchId/fetch-performance', async (req: AuthRequest, res:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 绩效系数映射
|
// 绩效系数:优先用记录中保存的 coefficient,回退到组织缺省配置,再回退到硬编码
|
||||||
const gradeCoefficients: Record<string, number> = { A: 1.2, B: 1.0, C: 0.8, D: 0.6 }
|
const orgForCoeff = await prisma.organization.findUnique({ where: { id: orgId }, select: { defaultGradeCoefficients: true } })
|
||||||
|
const DEFAULT_GRADE_COEFFICIENTS: Record<string, number> = (orgForCoeff?.defaultGradeCoefficients as Record<string, number>) || { A: 1.2, B: 1.0, C: 0.8, D: 0.6 }
|
||||||
|
|
||||||
let filled = 0
|
let filled = 0
|
||||||
let totalAmount = 0
|
let totalAmount = 0
|
||||||
@@ -969,7 +990,18 @@ router.post('/batches/:batchId/fetch-performance', async (req: AuthRequest, res:
|
|||||||
|
|
||||||
const perfRecord = perfMap.get(entry.employeeId)
|
const perfRecord = perfMap.get(entry.employeeId)
|
||||||
const grade = perfRecord?.grade || null
|
const grade = perfRecord?.grade || null
|
||||||
const coefficient = perfRecord ? (gradeCoefficients[perfRecord.grade] || 1.0) : 1.0
|
// 系数优先级:记录中保存的 coefficient > 模板 gradeRules 中的 coefficient > 系统默认值
|
||||||
|
let coefficient = 1.0
|
||||||
|
if (perfRecord) {
|
||||||
|
if (perfRecord.coefficient != null) {
|
||||||
|
coefficient = perfRecord.coefficient
|
||||||
|
} else {
|
||||||
|
// 查绩效记录关联的模板
|
||||||
|
const perfRecWithTemplate = performanceRecords.find(r => r.employeeId === entry.employeeId && r.templateId)
|
||||||
|
const templateCoeffs = perfRecWithTemplate?.templateId ? templateCoeffMap.get(perfRecWithTemplate.templateId) : null
|
||||||
|
coefficient = (templateCoeffs && perfRecord.grade in templateCoeffs) ? templateCoeffs[perfRecord.grade] : (DEFAULT_GRADE_COEFFICIENTS[perfRecord.grade] || 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
const calculatedPerfSalary = Math.round(basePerfSalary * coefficient * 100) / 100
|
const calculatedPerfSalary = Math.round(basePerfSalary * coefficient * 100) / 100
|
||||||
|
|
||||||
await prisma.batchEntry.update({
|
await prisma.batchEntry.update({
|
||||||
|
|||||||
@@ -1271,7 +1271,7 @@ router.get('/:employeeId/performance', authMiddleware, async (req: AuthRequest,
|
|||||||
|
|
||||||
router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest, res, next) => {
|
router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { period, periodType, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer, templateId, dimensionScores } = req.body
|
const { period, periodType, score, grade, coefficient, result, summary, improvementPlan, employeeAck, ackDate, reviewer, templateId, dimensionScores } = req.body
|
||||||
const record = await prisma.performanceRecord.upsert({
|
const record = await prisma.performanceRecord.upsert({
|
||||||
where: { employeeId_period: { employeeId: req.params.employeeId, period } },
|
where: { employeeId_period: { employeeId: req.params.employeeId, period } },
|
||||||
create: {
|
create: {
|
||||||
@@ -1281,6 +1281,7 @@ router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest,
|
|||||||
periodType: periodType || 'MONTHLY',
|
periodType: periodType || 'MONTHLY',
|
||||||
score: score || 0,
|
score: score || 0,
|
||||||
grade: grade || 'B',
|
grade: grade || 'B',
|
||||||
|
coefficient: coefficient != null ? Number(coefficient) : 1.0,
|
||||||
result: result || 'QUALIFIED',
|
result: result || 'QUALIFIED',
|
||||||
summary,
|
summary,
|
||||||
improvementPlan,
|
improvementPlan,
|
||||||
@@ -1295,6 +1296,7 @@ router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest,
|
|||||||
periodType,
|
periodType,
|
||||||
score,
|
score,
|
||||||
grade,
|
grade,
|
||||||
|
coefficient: coefficient != null ? Number(coefficient) : undefined,
|
||||||
result,
|
result,
|
||||||
summary,
|
summary,
|
||||||
improvementPlan,
|
improvementPlan,
|
||||||
@@ -1312,7 +1314,7 @@ router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest,
|
|||||||
|
|
||||||
router.put('/:employeeId/performance/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
router.put('/:employeeId/performance/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { period, periodType, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer, templateId, dimensionScores } = req.body
|
const { period, periodType, score, grade, coefficient, result, summary, improvementPlan, employeeAck, ackDate, reviewer, templateId, dimensionScores } = req.body
|
||||||
const record = await prisma.performanceRecord.findFirst({
|
const record = await prisma.performanceRecord.findFirst({
|
||||||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||||||
})
|
})
|
||||||
@@ -1324,6 +1326,7 @@ router.put('/:employeeId/performance/:recordId', authMiddleware, async (req: Aut
|
|||||||
periodType,
|
periodType,
|
||||||
score,
|
score,
|
||||||
grade,
|
grade,
|
||||||
|
coefficient: coefficient != null ? Number(coefficient) : undefined,
|
||||||
result,
|
result,
|
||||||
summary,
|
summary,
|
||||||
improvementPlan,
|
improvementPlan,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ router.get('/org', async (req: AuthRequest, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const org = await prisma.organization.findUnique({
|
const org = await prisma.organization.findUnique({
|
||||||
where: { id: req.user!.orgId },
|
where: { id: req.user!.orgId },
|
||||||
select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true, payrollDays: true, payrollReminderDays: true, retirementReminderEnabled: true, esignPolicyEnabled: true, esignPayslipEnabled: true, esignOnboardingEnabled: true, createdAt: true },
|
select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true, payrollDays: true, payrollReminderDays: true, retirementReminderEnabled: true, esignPolicyEnabled: true, esignPayslipEnabled: true, esignOnboardingEnabled: true, defaultGradeCoefficients: true, createdAt: true },
|
||||||
})
|
})
|
||||||
res.json({ success: true, data: org })
|
res.json({ success: true, data: org })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -39,7 +39,7 @@ router.get('/org', async (req: AuthRequest, res, next) => {
|
|||||||
// 更新企业信息
|
// 更新企业信息
|
||||||
router.put('/org', requireAdmin, async (req: AuthRequest, res, next) => {
|
router.put('/org', requireAdmin, async (req: AuthRequest, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { name, payrollDays, payrollReminderDays, city, contactName, contactPhone, retirementReminderEnabled, esignPolicyEnabled, esignPayslipEnabled, esignOnboardingEnabled } = req.body as { name?: string; payrollDays?: number[]; payrollReminderDays?: number; city?: string; contactName?: string; contactPhone?: string; retirementReminderEnabled?: boolean; esignPolicyEnabled?: boolean; esignPayslipEnabled?: boolean; esignOnboardingEnabled?: boolean }
|
const { name, payrollDays, payrollReminderDays, city, contactName, contactPhone, retirementReminderEnabled, esignPolicyEnabled, esignPayslipEnabled, esignOnboardingEnabled, defaultGradeCoefficients } = req.body as { name?: string; payrollDays?: number[]; payrollReminderDays?: number; city?: string; contactName?: string; contactPhone?: string; retirementReminderEnabled?: boolean; esignPolicyEnabled?: boolean; esignPayslipEnabled?: boolean; esignOnboardingEnabled?: boolean; defaultGradeCoefficients?: Record<string, number> }
|
||||||
const updateData: any = {}
|
const updateData: any = {}
|
||||||
if (name) updateData.name = name
|
if (name) updateData.name = name
|
||||||
if (payrollDays !== undefined) updateData.payrollDays = payrollDays
|
if (payrollDays !== undefined) updateData.payrollDays = payrollDays
|
||||||
@@ -51,10 +51,11 @@ router.put('/org', requireAdmin, async (req: AuthRequest, res, next) => {
|
|||||||
if (esignPolicyEnabled !== undefined) updateData.esignPolicyEnabled = esignPolicyEnabled
|
if (esignPolicyEnabled !== undefined) updateData.esignPolicyEnabled = esignPolicyEnabled
|
||||||
if (esignPayslipEnabled !== undefined) updateData.esignPayslipEnabled = esignPayslipEnabled
|
if (esignPayslipEnabled !== undefined) updateData.esignPayslipEnabled = esignPayslipEnabled
|
||||||
if (esignOnboardingEnabled !== undefined) updateData.esignOnboardingEnabled = esignOnboardingEnabled
|
if (esignOnboardingEnabled !== undefined) updateData.esignOnboardingEnabled = esignOnboardingEnabled
|
||||||
|
if (defaultGradeCoefficients !== undefined) updateData.defaultGradeCoefficients = defaultGradeCoefficients
|
||||||
const org = await prisma.organization.update({
|
const org = await prisma.organization.update({
|
||||||
where: { id: req.user!.orgId },
|
where: { id: req.user!.orgId },
|
||||||
data: updateData,
|
data: updateData,
|
||||||
select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true, payrollDays: true, payrollReminderDays: true, retirementReminderEnabled: true, esignPolicyEnabled: true, esignPayslipEnabled: true, esignOnboardingEnabled: true },
|
select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true, payrollDays: true, payrollReminderDays: true, retirementReminderEnabled: true, esignPolicyEnabled: true, esignPayslipEnabled: true, esignOnboardingEnabled: true, defaultGradeCoefficients: true },
|
||||||
})
|
})
|
||||||
res.json({ success: true, data: org })
|
res.json({ success: true, data: org })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -429,12 +429,14 @@ export async function detectMonthlyTasks(orgId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 社保和公积金按账户 paymentDay 生成提醒
|
// 社保和公积金按账户 paymentDay 生成提醒,未设置时回退到组织 socialInsCutoffDay
|
||||||
|
const org = await prisma.organization.findUnique({ where: { id: orgId }, select: { socialInsCutoffDay: true } })
|
||||||
|
const defaultCutoffDay = org?.socialInsCutoffDay || 15
|
||||||
const accounts = await prisma.socialAccount.findMany({
|
const accounts = await prisma.socialAccount.findMany({
|
||||||
where: { orgId, status: 'ACTIVE', paymentDay: { not: null } },
|
where: { orgId, status: 'ACTIVE' },
|
||||||
})
|
})
|
||||||
for (const acc of accounts) {
|
for (const acc of accounts) {
|
||||||
const day = acc.paymentDay!
|
const day = acc.paymentDay || defaultCutoffDay
|
||||||
if (today >= day) {
|
if (today >= day) {
|
||||||
const deadline = new Date(now.getFullYear(), now.getMonth(), day)
|
const deadline = new Date(now.getFullYear(), now.getMonth(), day)
|
||||||
const typeLabel = acc.type === 'SOCIAL' ? '社保' : '公积金'
|
const typeLabel = acc.type === 'SOCIAL' ? '社保' : '公积金'
|
||||||
@@ -721,8 +723,9 @@ export async function getDashboardData(orgId: string) {
|
|||||||
where: { orgId, batch: { month: currentMonth, status: 'ARCHIVED' } },
|
where: { orgId, batch: { month: currentMonth, status: 'ARCHIVED' } },
|
||||||
select: { baseSalary: true, overtimePay: true, allowance: true, deduction: true, bonus: true, totalPay: true, socialEmp: true, socialOrg: true, housingEmp: true, housingOrg: true, tax: true, netPay: true, employeeId: true },
|
select: { baseSalary: true, overtimePay: true, allowance: true, deduction: true, bonus: true, totalPay: true, socialEmp: true, socialOrg: true, housingEmp: true, housingOrg: true, tax: true, netPay: true, employeeId: true },
|
||||||
}),
|
}),
|
||||||
prisma.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }),
|
// 社保/公积金配置:优先查 SocialYearStandard(新架构),回退到旧表
|
||||||
prisma.housingFundConfig.findFirst({ where: { orgId, isCurrent: true } }),
|
prisma.socialYearStandard.findFirst({ where: { orgId, isCurrent: true, account: { type: 'SOCIAL' } } }).then(async r => r as any || (await prisma.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }) as any)),
|
||||||
|
prisma.socialYearStandard.findFirst({ where: { orgId, isCurrent: true, account: { type: 'HOUSING' } } }).then(async r => r as any || (await prisma.housingFundConfig.findFirst({ where: { orgId, isCurrent: true } }) as any)),
|
||||||
prisma.laborContract.count({
|
prisma.laborContract.count({
|
||||||
where: { orgId, createdAt: { gte: monthStart, lte: monthEnd } },
|
where: { orgId, createdAt: { gte: monthStart, lte: monthEnd } },
|
||||||
}),
|
}),
|
||||||
@@ -1498,8 +1501,9 @@ export async function getComplianceScore(orgId: string) {
|
|||||||
prisma.attendanceConfirmation.count({ where: { orgId, status: 'PENDING' } }),
|
prisma.attendanceConfirmation.count({ where: { orgId, status: 'PENDING' } }),
|
||||||
prisma.payslip.count({ where: { orgId, confirmedAt: null } }),
|
prisma.payslip.count({ where: { orgId, confirmedAt: null } }),
|
||||||
prisma.payslip.count({ where: { orgId } }),
|
prisma.payslip.count({ where: { orgId } }),
|
||||||
prisma.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }),
|
// 社保/公积金配置:优先查 SocialYearStandard(新架构),回退到旧表
|
||||||
prisma.housingFundConfig.findFirst({ where: { orgId, isCurrent: true } }),
|
prisma.socialYearStandard.findFirst({ where: { orgId, isCurrent: true, account: { type: 'SOCIAL' } } }).then(async r => r ? true : !!(await prisma.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }))) as Promise<boolean>,
|
||||||
|
prisma.socialYearStandard.findFirst({ where: { orgId, isCurrent: true, account: { type: 'HOUSING' } } }).then(async r => r ? true : !!(await prisma.housingFundConfig.findFirst({ where: { orgId, isCurrent: true } }))) as Promise<boolean>,
|
||||||
prisma.riskItem.count({
|
prisma.riskItem.count({
|
||||||
where: {
|
where: {
|
||||||
orgId,
|
orgId,
|
||||||
@@ -1519,13 +1523,18 @@ export async function getComplianceScore(orgId: string) {
|
|||||||
? Math.max(0, 100 - Math.round((unconfirmedPayslips / totalPayslips) * 100))
|
? Math.max(0, 100 - Math.round((unconfirmedPayslips / totalPayslips) * 100))
|
||||||
: 100
|
: 100
|
||||||
const socialScore = (socialConfig ? 50 : 0) + (housingConfig ? 50 : 0)
|
const socialScore = (socialConfig ? 50 : 0) + (housingConfig ? 50 : 0)
|
||||||
|
// 社保公积金待办:当月未完成的缴纳任务
|
||||||
|
const currentMonthStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
const socialTodoCount = await prisma.riskItem.count({
|
||||||
|
where: { orgId, status: 'PENDING', type: 'MONTHLY', title: { startsWith: `${currentMonthStr}月 缴纳` } },
|
||||||
|
})
|
||||||
|
|
||||||
const dimensions = [
|
const dimensions = [
|
||||||
{ key: 'contract', name: '合同管理', score: contractScore, todoCount: unsignedContracts + pendingSignContracts + expiringContracts },
|
{ key: 'contract', name: '合同管理', score: contractScore, todoCount: unsignedContracts + pendingSignContracts + expiringContracts },
|
||||||
{ key: 'policy', name: '规章制度', score: policyScore, todoCount: policiesWithoutPublish },
|
{ key: 'policy', name: '规章制度', score: policyScore, todoCount: policiesWithoutPublish },
|
||||||
{ key: 'attendance', name: '考勤工时', score: attendanceScore, todoCount: attendanceUnconfirmed },
|
{ key: 'attendance', name: '考勤工时', score: attendanceScore, todoCount: attendanceUnconfirmed },
|
||||||
{ key: 'salary', name: '薪酬工资', score: salaryScore, todoCount: unconfirmedPayslips },
|
{ key: 'salary', name: '薪酬工资', score: salaryScore, todoCount: unconfirmedPayslips },
|
||||||
{ key: 'social', name: '社保公积金', score: socialScore, todoCount: 0 },
|
{ key: 'social', name: '社保公积金', score: socialScore, todoCount: socialTodoCount },
|
||||||
]
|
]
|
||||||
|
|
||||||
const overallScore = Math.round(
|
const overallScore = Math.round(
|
||||||
@@ -1679,10 +1688,17 @@ export async function getHealthCheck(orgId: string) {
|
|||||||
// democracyProgress 是 JSONB,用 status 字段判断未完成
|
// democracyProgress 是 JSONB,用 status 字段判断未完成
|
||||||
prisma.policyDocument.count({ where: { orgId, status: { not: 'PUBLISHED' } } }),
|
prisma.policyDocument.count({ where: { orgId, status: { not: 'PUBLISHED' } } }),
|
||||||
prisma.policyDocument.count({ where: { orgId } }),
|
prisma.policyDocument.count({ where: { orgId } }),
|
||||||
prisma.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }),
|
// 社保/公积金配置:优先查 SocialYearStandard(新架构),回退到旧表
|
||||||
prisma.housingFundConfig.findFirst({ where: { orgId, isCurrent: true } }),
|
prisma.socialYearStandard.findFirst({ where: { orgId, isCurrent: true, account: { type: 'SOCIAL' } } }).then(async r => r ? true : !!(await prisma.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }))) as Promise<boolean>,
|
||||||
// 社保记录按员工去重
|
prisma.socialYearStandard.findFirst({ where: { orgId, isCurrent: true, account: { type: 'HOUSING' } } }).then(async r => r ? true : !!(await prisma.housingFundConfig.findFirst({ where: { orgId, isCurrent: true } }))) as Promise<boolean>,
|
||||||
prisma.employeeSocialInsRecord.groupBy({ by: ['employeeId'], where: { orgId, endMonth: null }, _count: true }).then(r => r.length),
|
// 社保记录:计算有正式劳动合同但无社保记录的在职员工数(实习/劳务/外包等不缴社保)
|
||||||
|
Promise.all([
|
||||||
|
prisma.employee.findMany({
|
||||||
|
where: { orgId, status: 'ACTIVE', contracts: { some: { contractType: { in: ['FIXED', 'UNFIXED'] } } } },
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
|
prisma.employeeSocialInsRecord.groupBy({ by: ['employeeId'], where: { orgId, endMonth: null }, _count: true }).then(r => new Set(r.map(x => x.employeeId))),
|
||||||
|
]).then(([emps, socialEmpIds]) => emps.filter(e => !socialEmpIds.has(e.id)).length),
|
||||||
prisma.overtimeRecord.count({ where: { orgId, weekdayHours: { gt: 36 } } }),
|
prisma.overtimeRecord.count({ where: { orgId, weekdayHours: { gt: 36 } } }),
|
||||||
// 解聘合规
|
// 解聘合规
|
||||||
prisma.terminationRecord.count({ where: { orgId } }),
|
prisma.terminationRecord.count({ where: { orgId } }),
|
||||||
@@ -1703,6 +1719,7 @@ export async function getHealthCheck(orgId: string) {
|
|||||||
key: 'contract',
|
key: 'contract',
|
||||||
name: '合同管理',
|
name: '合同管理',
|
||||||
score: Math.max(0, 100 - unsignedContracts * 15 - pendingSignContracts * 10 - expiringContracts * 5),
|
score: Math.max(0, 100 - unsignedContracts * 15 - pendingSignContracts * 10 - expiringContracts * 5),
|
||||||
|
todoCount: unsignedContracts + pendingSignContracts + expiringContracts,
|
||||||
findings: [
|
findings: [
|
||||||
unsignedContracts > 0 ? `${unsignedContracts} 名员工未签书面合同,存在双倍工资风险` : '全员已有合同记录',
|
unsignedContracts > 0 ? `${unsignedContracts} 名员工未签书面合同,存在双倍工资风险` : '全员已有合同记录',
|
||||||
pendingSignContracts > 0 ? `${pendingSignContracts} 份合同待签署(已创建未完成签署)` : '',
|
pendingSignContracts > 0 ? `${pendingSignContracts} 份合同待签署(已创建未完成签署)` : '',
|
||||||
@@ -1720,6 +1737,7 @@ export async function getHealthCheck(orgId: string) {
|
|||||||
score: totalPayslips > 0
|
score: totalPayslips > 0
|
||||||
? Math.max(0, 100 - Math.round((unconfirmedPayslips / totalPayslips) * 100))
|
? Math.max(0, 100 - Math.round((unconfirmedPayslips / totalPayslips) * 100))
|
||||||
: 100,
|
: 100,
|
||||||
|
todoCount: unconfirmedPayslips,
|
||||||
findings: [
|
findings: [
|
||||||
totalPayslips > 0
|
totalPayslips > 0
|
||||||
? `${unconfirmedPayslips}/${totalPayslips} 张工资条未确认`
|
? `${unconfirmedPayslips}/${totalPayslips} 张工资条未确认`
|
||||||
@@ -1737,6 +1755,9 @@ export async function getHealthCheck(orgId: string) {
|
|||||||
(housingConfig ? 30 : 0) +
|
(housingConfig ? 30 : 0) +
|
||||||
(employeesNoSocial === 0 ? 20 : Math.max(0, 20 - employeesNoSocial * 2))
|
(employeesNoSocial === 0 ? 20 : Math.max(0, 20 - employeesNoSocial * 2))
|
||||||
),
|
),
|
||||||
|
todoCount: await prisma.riskItem.count({
|
||||||
|
where: { orgId, status: 'PENDING', type: 'MONTHLY', title: { startsWith: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}月 缴纳` } },
|
||||||
|
}),
|
||||||
findings: [
|
findings: [
|
||||||
socialConfig ? '社保配置已设置' : '未配置社保比例,建议尽快设置',
|
socialConfig ? '社保配置已设置' : '未配置社保比例,建议尽快设置',
|
||||||
housingConfig ? '公积金配置已设置' : '未配置公积金比例',
|
housingConfig ? '公积金配置已设置' : '未配置公积金比例',
|
||||||
@@ -1751,6 +1772,7 @@ export async function getHealthCheck(orgId: string) {
|
|||||||
key: 'attendance',
|
key: 'attendance',
|
||||||
name: '考勤加班',
|
name: '考勤加班',
|
||||||
score: Math.max(0, 100 - overtimeExcessive * 10),
|
score: Math.max(0, 100 - overtimeExcessive * 10),
|
||||||
|
todoCount: overtimeExcessive,
|
||||||
findings: [
|
findings: [
|
||||||
overtimeExcessive > 0 ? `${overtimeExcessive} 条加班记录月超时36小时,存在违法风险` : '加班时长合规',
|
overtimeExcessive > 0 ? `${overtimeExcessive} 条加班记录月超时36小时,存在违法风险` : '加班时长合规',
|
||||||
attendanceRecords > 0 ? `已记录 ${attendanceRecords} 条考勤数据` : '暂无考勤记录,建议建立考勤制度',
|
attendanceRecords > 0 ? `已记录 ${attendanceRecords} 条考勤数据` : '暂无考勤记录,建议建立考勤制度',
|
||||||
@@ -1766,6 +1788,7 @@ export async function getHealthCheck(orgId: string) {
|
|||||||
score: totalPolicies > 0
|
score: totalPolicies > 0
|
||||||
? Math.max(0, 100 - policiesWithoutPublish * 12)
|
? Math.max(0, 100 - policiesWithoutPublish * 12)
|
||||||
: 60,
|
: 60,
|
||||||
|
todoCount: policiesWithoutPublish,
|
||||||
findings: [
|
findings: [
|
||||||
totalPolicies === 0 ? '尚未上传任何规章制度,建议建立基础制度体系' : `已管理 ${totalPolicies} 项制度`,
|
totalPolicies === 0 ? '尚未上传任何规章制度,建议建立基础制度体系' : `已管理 ${totalPolicies} 项制度`,
|
||||||
policiesWithoutPublish > 0 ? `${policiesWithoutPublish} 项制度未完成民主程序,仲裁中可能无效` : '所有制度已完成民主公示',
|
policiesWithoutPublish > 0 ? `${policiesWithoutPublish} 项制度未完成民主程序,仲裁中可能无效` : '所有制度已完成民主公示',
|
||||||
@@ -1784,6 +1807,7 @@ export async function getHealthCheck(orgId: string) {
|
|||||||
(terminationsWithChecklist / Math.max(1, completedTerminations)) * 40
|
(terminationsWithChecklist / Math.max(1, completedTerminations)) * 40
|
||||||
)
|
)
|
||||||
: 100,
|
: 100,
|
||||||
|
todoCount: totalTerminations - completedTerminations,
|
||||||
findings: [
|
findings: [
|
||||||
totalTerminations > 0
|
totalTerminations > 0
|
||||||
? `共 ${totalTerminations} 起解聘,${completedTerminations} 起已完成流程`
|
? `共 ${totalTerminations} 起解聘,${completedTerminations} 起已完成流程`
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
|
|||||||
esignTrainingEnabled: false,
|
esignTrainingEnabled: false,
|
||||||
esignPerformanceEnabled: false,
|
esignPerformanceEnabled: false,
|
||||||
esignDisciplinaryEnabled: false,
|
esignDisciplinaryEnabled: false,
|
||||||
|
defaultGradeCoefficients: { A: 1.2, B: 1.0, C: 0.8, D: 0.6 } as Record<string, number>,
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -125,6 +126,7 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
|
|||||||
esignTrainingEnabled: orgData.esignTrainingEnabled || false,
|
esignTrainingEnabled: orgData.esignTrainingEnabled || false,
|
||||||
esignPerformanceEnabled: orgData.esignPerformanceEnabled || false,
|
esignPerformanceEnabled: orgData.esignPerformanceEnabled || false,
|
||||||
esignDisciplinaryEnabled: orgData.esignDisciplinaryEnabled || false,
|
esignDisciplinaryEnabled: orgData.esignDisciplinaryEnabled || false,
|
||||||
|
defaultGradeCoefficients: orgData.defaultGradeCoefficients || { A: 1.2, B: 1.0, C: 0.8, D: 0.6 },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [orgData])
|
}, [orgData])
|
||||||
@@ -180,6 +182,25 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
|
|||||||
<span className="text-xs text-gray-500">天前在工作台提醒发薪</span>
|
<span className="text-xs text-gray-500">天前在工作台提醒发薪</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>缺省绩效系数</Label>
|
||||||
|
<p className="text-xs text-gray-500 mt-1 mb-2">不选择绩效模板时,编辑绩效记录自动带出的等级系数。绩效工资 = 绩效工资基数 × 系数。</p>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{(['A', 'B', 'C', 'D'] as const).map(g => (
|
||||||
|
<div key={g}>
|
||||||
|
<Label className="text-xs">等级 {g}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min={0}
|
||||||
|
value={form.defaultGradeCoefficients[g] ?? 1.0}
|
||||||
|
onChange={(e) => setForm({ ...form, defaultGradeCoefficients: { ...form.defaultGradeCoefficients, [g]: Number(e.target.value) } })}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Button onClick={() => onSave(form)} disabled={saving}>
|
<Button onClick={() => onSave(form)} disabled={saving}>
|
||||||
{saving ? '保存中...' : '保存'}
|
{saving ? '保存中...' : '保存'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { rosterApi } from '../../lib/api-services'
|
import { rosterApi, settingsApi } from '../../lib/api-services'
|
||||||
import Card from "../../components/ui/Card"
|
import Card from "../../components/ui/Card"
|
||||||
import Button from "../../components/ui/Button"
|
import Button from "../../components/ui/Button"
|
||||||
import { Input, Label, Select } from "../../components/ui/Input"
|
import { Input, Label, Select } from "../../components/ui/Input"
|
||||||
@@ -11,7 +11,7 @@ import { AlertTriangle, Check } from "lucide-react"
|
|||||||
export default function PerformanceInfo({ employeeId, records }: { employeeId: string; records: any[] }) {
|
export default function PerformanceInfo({ employeeId, records }: { employeeId: string; records: any[] }) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [showForm, setShowForm] = useState(false)
|
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: '', templateId: '' })
|
const [form, setForm] = useState({ period: '', periodType: 'MONTHLY' as 'MONTHLY' | 'QUARTERLY' | 'YEARLY', score: 80, grade: 'B', coefficient: 1.0, result: 'QUALIFIED', summary: '', improvementPlan: '', employeeAck: false, ackDate: '', reviewer: '', templateId: '' })
|
||||||
const [dimensionScores, setDimensionScores] = useState<Record<string, number>>({})
|
const [dimensionScores, setDimensionScores] = useState<Record<string, number>>({})
|
||||||
|
|
||||||
const { data: templates } = useQuery({
|
const { data: templates } = useQuery({
|
||||||
@@ -19,6 +19,12 @@ export default function PerformanceInfo({ employeeId, records }: { employeeId: s
|
|||||||
queryFn: () => rosterApi.performanceTemplates(),
|
queryFn: () => rosterApi.performanceTemplates(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 获取组织缺省绩效系数配置
|
||||||
|
const { data: orgData } = useQuery<any>({
|
||||||
|
queryKey: ['org-settings'],
|
||||||
|
queryFn: () => settingsApi.org(),
|
||||||
|
})
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: any) => rosterApi.performance(employeeId, data),
|
mutationFn: (data: any) => rosterApi.performance(employeeId, data),
|
||||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false); setDimensionScores({}) },
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false); setDimensionScores({}) },
|
||||||
@@ -31,6 +37,24 @@ export default function PerformanceInfo({ employeeId, records }: { employeeId: s
|
|||||||
|
|
||||||
const resultMap: Record<string, string> = { EXCELLENT: '优秀', QUALIFIED: '合格', NEED_IMPROVE: '需改进', UNQUALIFIED: '不胜任' }
|
const resultMap: Record<string, string> = { EXCELLENT: '优秀', QUALIFIED: '合格', NEED_IMPROVE: '需改进', UNQUALIFIED: '不胜任' }
|
||||||
|
|
||||||
|
const selectedTemplate = (templates || []).find((t: any) => t.id === form.templateId)
|
||||||
|
const dimensions: any[] = selectedTemplate?.dimensions || []
|
||||||
|
|
||||||
|
// 等级系数映射:优先从选中模板的 gradeRules 取,回退到组织缺省配置,再回退到硬编码默认值
|
||||||
|
const SYSTEM_DEFAULT_COEFFICIENTS: Record<string, number> = orgData?.defaultGradeCoefficients || { A: 1.2, B: 1.0, C: 0.8, D: 0.6 }
|
||||||
|
const DEFAULT_COEFFICIENTS: Record<string, number> = (() => {
|
||||||
|
if (selectedTemplate?.gradeRules && Array.isArray(selectedTemplate.gradeRules)) {
|
||||||
|
const map: Record<string, number> = { ...SYSTEM_DEFAULT_COEFFICIENTS }
|
||||||
|
for (const rule of selectedTemplate.gradeRules) {
|
||||||
|
if (rule.grade && rule.coefficient != null) {
|
||||||
|
map[rule.grade] = Number(rule.coefficient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
return SYSTEM_DEFAULT_COEFFICIENTS
|
||||||
|
})()
|
||||||
|
|
||||||
// 根据得分自动计算等级和结果
|
// 根据得分自动计算等级和结果
|
||||||
const scoreToGrade = (score: number): { grade: string; result: string } => {
|
const scoreToGrade = (score: number): { grade: string; result: string } => {
|
||||||
if (score >= 90) return { grade: 'A', result: 'EXCELLENT' }
|
if (score >= 90) return { grade: 'A', result: 'EXCELLENT' }
|
||||||
@@ -41,11 +65,14 @@ export default function PerformanceInfo({ employeeId, records }: { employeeId: s
|
|||||||
|
|
||||||
const handleScoreChange = (score: number) => {
|
const handleScoreChange = (score: number) => {
|
||||||
const { grade, result } = scoreToGrade(score)
|
const { grade, result } = scoreToGrade(score)
|
||||||
setForm({ ...form, score, grade, result })
|
const newCoeff = grade !== form.grade ? (DEFAULT_COEFFICIENTS[grade] ?? 1.0) : form.coefficient
|
||||||
|
setForm({ ...form, score, grade, result, coefficient: newCoeff })
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedTemplate = (templates || []).find((t: any) => t.id === form.templateId)
|
const handleGradeChange = (grade: string) => {
|
||||||
const dimensions: any[] = selectedTemplate?.dimensions || []
|
const newCoeff = DEFAULT_COEFFICIENTS[grade] ?? 1.0
|
||||||
|
setForm({ ...form, grade, coefficient: newCoeff })
|
||||||
|
}
|
||||||
|
|
||||||
const handleDimensionChange = (name: string, score: number) => {
|
const handleDimensionChange = (name: string, score: number) => {
|
||||||
const updated = { ...dimensionScores, [name]: score }
|
const updated = { ...dimensionScores, [name]: score }
|
||||||
@@ -58,7 +85,8 @@ export default function PerformanceInfo({ employeeId, records }: { employeeId: s
|
|||||||
return sum + (s / maxScore) * weight * 100
|
return sum + (s / maxScore) * weight * 100
|
||||||
}, 0)
|
}, 0)
|
||||||
const { grade, result } = scoreToGrade(Math.round(totalScore))
|
const { grade, result } = scoreToGrade(Math.round(totalScore))
|
||||||
setForm(prev => ({ ...prev, score: Math.round(totalScore), grade, result }))
|
const newCoeff = grade !== form.grade ? (DEFAULT_COEFFICIENTS[grade] ?? 1.0) : form.coefficient
|
||||||
|
setForm(prev => ({ ...prev, score: Math.round(totalScore), grade, result, coefficient: newCoeff }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,12 +148,16 @@ export default function PerformanceInfo({ employeeId, records }: { employeeId: s
|
|||||||
<>
|
<>
|
||||||
<div><Label>考核得分</Label><Input type="number" value={form.score} onChange={(e) => handleScoreChange(Number(e.target.value))} /></div>
|
<div><Label>考核得分</Label><Input type="number" value={form.score} onChange={(e) => handleScoreChange(Number(e.target.value))} /></div>
|
||||||
<div><Label>等级(由得分自动计算)</Label>
|
<div><Label>等级(由得分自动计算)</Label>
|
||||||
<Select value={form.grade} onChange={(e) => setForm({ ...form, grade: e.target.value })}>
|
<Select value={form.grade} onChange={(e) => handleGradeChange(e.target.value)}>
|
||||||
<option value="A">A</option><option value="B">B</option><option value="C">C</option><option value="D">D</option>
|
<option value="A">A</option><option value="B">B</option><option value="C">C</option><option value="D">D</option>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<div><Label>绩效系数</Label>
|
||||||
|
<Input type="number" step="0.1" min={0} value={form.coefficient} onChange={(e) => setForm({ ...form, coefficient: Number(e.target.value) })} placeholder="默认 A=1.2, B=1.0, C=0.8, D=0.6" />
|
||||||
|
<p className="text-xs text-gray-500 mt-1">用于薪资计算:绩效工资 = 绩效工资基数 × 系数</p>
|
||||||
|
</div>
|
||||||
<div><Label>考核结果(由得分自动计算)</Label>
|
<div><Label>考核结果(由得分自动计算)</Label>
|
||||||
<Select value={form.result} onChange={(e) => setForm({ ...form, result: e.target.value })}>
|
<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>)}
|
{Object.entries(resultMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||||
@@ -155,7 +187,7 @@ export default function PerformanceInfo({ employeeId, records }: { employeeId: s
|
|||||||
<span className={`px-2 py-0.5 rounded text-xs ${r.result === 'EXCELLENT' ? 'bg-green-50 text-safe' : r.result === 'QUALIFIED' ? 'bg-blue-50 text-blue-600' : r.result === 'NEED_IMPROVE' ? 'bg-amber-50 text-amber-600' : 'bg-red-50 text-danger'}`}>
|
<span className={`px-2 py-0.5 rounded text-xs ${r.result === 'EXCELLENT' ? 'bg-green-50 text-safe' : r.result === 'QUALIFIED' ? 'bg-blue-50 text-blue-600' : r.result === 'NEED_IMPROVE' ? 'bg-amber-50 text-amber-600' : 'bg-red-50 text-danger'}`}>
|
||||||
{resultMap[r.result] || r.result}
|
{resultMap[r.result] || r.result}
|
||||||
</span>
|
</span>
|
||||||
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 text-xs">得分 {r.score} · 等级 {r.grade}</span>
|
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 text-xs">得分 {r.score} · 等级 {r.grade} · 系数 {r.coefficient ?? 1.0}</span>
|
||||||
</div>
|
</div>
|
||||||
{r.dimensionScores && Object.keys(r.dimensionScores).length > 0 && (
|
{r.dimensionScores && Object.keys(r.dimensionScores).length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Search, Plus, Edit2, Trash2, X, LayoutTemplate } from 'lucide-react'
|
import { Search, Plus, Edit2, Trash2, X, LayoutTemplate } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { rosterApi, employeeApi } from '../../lib/api-services'
|
import { rosterApi, employeeApi, settingsApi } from '../../lib/api-services'
|
||||||
import api from '../../lib/api'
|
import api from '../../lib/api'
|
||||||
import { usePageSize } from '../../hooks/usePageSize'
|
import { usePageSize } from '../../hooks/usePageSize'
|
||||||
import { Input, Label, Select } from '../../components/ui/Input'
|
import { Input, Label, Select } from '../../components/ui/Input'
|
||||||
@@ -203,6 +203,12 @@ function PerformanceForm({ employees, templates, record, onSubmit, onClose }: {
|
|||||||
onSubmit: (data: any) => void
|
onSubmit: (data: any) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
|
// 获取组织缺省绩效系数配置
|
||||||
|
const { data: orgData } = useQuery<any>({
|
||||||
|
queryKey: ['org-settings'],
|
||||||
|
queryFn: () => settingsApi.org(),
|
||||||
|
})
|
||||||
|
|
||||||
const detectPeriodType = (period: string, fallback?: string): string => {
|
const detectPeriodType = (period: string, fallback?: string): string => {
|
||||||
if (/^\d{4}-Q[1-4]$/.test(period)) return 'QUARTERLY'
|
if (/^\d{4}-Q[1-4]$/.test(period)) return 'QUARTERLY'
|
||||||
if (/^\d{4}$/.test(period)) return 'YEARLY'
|
if (/^\d{4}$/.test(period)) return 'YEARLY'
|
||||||
@@ -218,6 +224,7 @@ function PerformanceForm({ employees, templates, record, onSubmit, onClose }: {
|
|||||||
periodType: initialPeriodType,
|
periodType: initialPeriodType,
|
||||||
score: record?.score || 80,
|
score: record?.score || 80,
|
||||||
grade: record?.grade || 'B',
|
grade: record?.grade || 'B',
|
||||||
|
coefficient: record?.coefficient ?? 1.0,
|
||||||
result: record?.result || 'QUALIFIED',
|
result: record?.result || 'QUALIFIED',
|
||||||
summary: record?.summary || '',
|
summary: record?.summary || '',
|
||||||
improvementPlan: record?.improvementPlan || '',
|
improvementPlan: record?.improvementPlan || '',
|
||||||
@@ -229,6 +236,21 @@ function PerformanceForm({ employees, templates, record, onSubmit, onClose }: {
|
|||||||
const selectedTemplate = templates.find((t: any) => t.id === form.templateId)
|
const selectedTemplate = templates.find((t: any) => t.id === form.templateId)
|
||||||
const dimensions: any[] = selectedTemplate?.dimensions || []
|
const dimensions: any[] = selectedTemplate?.dimensions || []
|
||||||
|
|
||||||
|
// 等级系数映射:优先从选中模板的 gradeRules 取,回退到组织缺省配置,再回退到硬编码默认值
|
||||||
|
const SYSTEM_DEFAULT_COEFFICIENTS: Record<string, number> = orgData?.defaultGradeCoefficients || { A: 1.2, B: 1.0, C: 0.8, D: 0.6 }
|
||||||
|
const DEFAULT_COEFFICIENTS: Record<string, number> = (() => {
|
||||||
|
if (selectedTemplate?.gradeRules && Array.isArray(selectedTemplate.gradeRules)) {
|
||||||
|
const map: Record<string, number> = { ...SYSTEM_DEFAULT_COEFFICIENTS }
|
||||||
|
for (const rule of selectedTemplate.gradeRules) {
|
||||||
|
if (rule.grade && rule.coefficient != null) {
|
||||||
|
map[rule.grade] = Number(rule.coefficient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
return SYSTEM_DEFAULT_COEFFICIENTS
|
||||||
|
})()
|
||||||
|
|
||||||
const scoreToGrade = (score: number): { grade: string; result: string } => {
|
const scoreToGrade = (score: number): { grade: string; result: string } => {
|
||||||
if (score >= 90) return { grade: 'A', result: 'EXCELLENT' }
|
if (score >= 90) return { grade: 'A', result: 'EXCELLENT' }
|
||||||
if (score >= 80) return { grade: 'B', result: 'QUALIFIED' }
|
if (score >= 80) return { grade: 'B', result: 'QUALIFIED' }
|
||||||
@@ -238,7 +260,15 @@ function PerformanceForm({ employees, templates, record, onSubmit, onClose }: {
|
|||||||
|
|
||||||
const handleScoreChange = (score: number) => {
|
const handleScoreChange = (score: number) => {
|
||||||
const { grade, result } = scoreToGrade(score)
|
const { grade, result } = scoreToGrade(score)
|
||||||
setForm({ ...form, score, grade, result })
|
// 得分变化导致等级变化时,自动更新系数为该等级的默认值
|
||||||
|
const newCoeff = grade !== form.grade ? (DEFAULT_COEFFICIENTS[grade] ?? 1.0) : form.coefficient
|
||||||
|
setForm({ ...form, score, grade, result, coefficient: newCoeff })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGradeChange = (grade: string) => {
|
||||||
|
// 手动切换等级时,自动更新系数为该等级的默认值
|
||||||
|
const newCoeff = DEFAULT_COEFFICIENTS[grade] ?? 1.0
|
||||||
|
setForm({ ...form, grade, coefficient: newCoeff })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDimensionChange = (name: string, score: number) => {
|
const handleDimensionChange = (name: string, score: number) => {
|
||||||
@@ -253,7 +283,8 @@ function PerformanceForm({ employees, templates, record, onSubmit, onClose }: {
|
|||||||
return sum + (s / maxScore) * weight * 100
|
return sum + (s / maxScore) * weight * 100
|
||||||
}, 0)
|
}, 0)
|
||||||
const { grade, result } = scoreToGrade(Math.round(totalScore))
|
const { grade, result } = scoreToGrade(Math.round(totalScore))
|
||||||
setForm(prev => ({ ...prev, score: Math.round(totalScore), grade, result }))
|
const newCoeff = grade !== form.grade ? (DEFAULT_COEFFICIENTS[grade] ?? 1.0) : form.coefficient
|
||||||
|
setForm(prev => ({ ...prev, score: Math.round(totalScore), grade, result, coefficient: newCoeff }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,7 +376,7 @@ function PerformanceForm({ employees, templates, record, onSubmit, onClose }: {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>等级(自动计算)</Label>
|
<Label>等级(自动计算)</Label>
|
||||||
<Select value={form.grade} onChange={(e) => setForm({ ...form, grade: e.target.value })}>
|
<Select value={form.grade} onChange={(e) => handleGradeChange(e.target.value)}>
|
||||||
<option value="A">A</option>
|
<option value="A">A</option>
|
||||||
<option value="B">B</option>
|
<option value="B">B</option>
|
||||||
<option value="C">C</option>
|
<option value="C">C</option>
|
||||||
@@ -366,6 +397,18 @@ function PerformanceForm({ employees, templates, record, onSubmit, onClose }: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div>
|
||||||
|
<Label>绩效系数</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min={0}
|
||||||
|
value={form.coefficient}
|
||||||
|
onChange={(e) => setForm({ ...form, coefficient: Number(e.target.value) })}
|
||||||
|
placeholder="默认 A=1.2, B=1.0, C=0.8, D=0.6,可手动调整"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">用于薪资计算:绩效工资 = 绩效工资基数 × 系数。等级变更时自动填充默认值,可手动修改。</p>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>考核结果(自动计算)</Label>
|
<Label>考核结果(自动计算)</Label>
|
||||||
<Select value={form.result} onChange={(e) => setForm({ ...form, result: e.target.value })}>
|
<Select value={form.result} onChange={(e) => setForm({ ...form, result: e.target.value })}>
|
||||||
@@ -504,6 +547,15 @@ function TemplateModal({ templates, onClose }: {
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{t.gradeRules && Array.isArray(t.gradeRules) && t.gradeRules.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{t.gradeRules.map((r: any) => (
|
||||||
|
<span key={r.grade} className="text-xs px-2 py-0.5 rounded bg-teal-50 text-teal-600">
|
||||||
|
{r.grade}×{r.coefficient ?? 1.0}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -524,6 +576,18 @@ function TemplateForm({ template, onSubmit, onClose }: {
|
|||||||
const [dimensions, setDimensions] = useState<any[]>(
|
const [dimensions, setDimensions] = useState<any[]>(
|
||||||
template?.dimensions || [{ name: '', weight: 100, maxScore: 100, description: '' }]
|
template?.dimensions || [{ name: '', weight: 100, maxScore: 100, description: '' }]
|
||||||
)
|
)
|
||||||
|
// 等级系数配置:[{ grade, coefficient }],从模板 gradeRules 中提取 coefficient
|
||||||
|
const [gradeCoefficients, setGradeCoefficients] = useState<Record<string, number>>(() => {
|
||||||
|
const defaults: Record<string, number> = { A: 1.2, B: 1.0, C: 0.8, D: 0.6 }
|
||||||
|
if (template?.gradeRules && Array.isArray(template.gradeRules)) {
|
||||||
|
for (const rule of template.gradeRules) {
|
||||||
|
if (rule.grade && rule.coefficient != null) {
|
||||||
|
defaults[rule.grade] = Number(rule.coefficient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaults
|
||||||
|
})
|
||||||
|
|
||||||
const addDimension = () => {
|
const addDimension = () => {
|
||||||
setDimensions([...dimensions, { name: '', weight: 0, maxScore: 100, description: '' }])
|
setDimensions([...dimensions, { name: '', weight: 0, maxScore: 100, description: '' }])
|
||||||
@@ -582,13 +646,40 @@ function TemplateForm({ template, onSubmit, onClose }: {
|
|||||||
<span className={`text-xs ${totalWeight === 100 ? 'text-green-600' : 'text-amber-600'}`}>权重合计:{totalWeight}%</span>
|
<span className={`text-xs ${totalWeight === 100 ? 'text-green-600' : 'text-amber-600'}`}>权重合计:{totalWeight}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>等级系数设置</Label>
|
||||||
|
<p className="text-xs text-gray-500 mb-2">设置各绩效等级对应的薪资系数,编辑绩效记录时自动带出。绩效工资 = 绩效工资基数 × 系数。</p>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{(['A', 'B', 'C', 'D'] as const).map(g => (
|
||||||
|
<div key={g}>
|
||||||
|
<Label className="text-xs">等级 {g}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min={0}
|
||||||
|
value={gradeCoefficients[g]}
|
||||||
|
onChange={(e) => setGradeCoefficients({ ...gradeCoefficients, [g]: Number(e.target.value) })}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} />
|
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} />
|
||||||
设为默认模板
|
设为默认模板
|
||||||
</label>
|
</label>
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button variant="secondary" size="sm" onClick={onClose}>取消</Button>
|
<Button variant="secondary" size="sm" onClick={onClose}>取消</Button>
|
||||||
<Button size="sm" onClick={() => onSubmit({ name, description, dimensions, isDefault })} disabled={!canSubmit}>
|
<Button size="sm" onClick={() => onSubmit({
|
||||||
|
name, description, dimensions, isDefault,
|
||||||
|
gradeRules: [
|
||||||
|
{ grade: 'A', result: 'EXCELLENT', minScore: 90, maxScore: 100, coefficient: gradeCoefficients.A },
|
||||||
|
{ grade: 'B', result: 'QUALIFIED', minScore: 80, maxScore: 89, coefficient: gradeCoefficients.B },
|
||||||
|
{ grade: 'C', result: 'NEED_IMPROVE', minScore: 60, maxScore: 79, coefficient: gradeCoefficients.C },
|
||||||
|
{ grade: 'D', result: 'UNQUALIFIED', minScore: 0, maxScore: 59, coefficient: gradeCoefficients.D },
|
||||||
|
],
|
||||||
|
})} disabled={!canSubmit}>
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user