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:
selfrelease
2026-08-19 15:16:06 +08:00
parent 81e136b707
commit f9324d4299
8 changed files with 245 additions and 39 deletions
+2
View File
@@ -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? // 改进计划(不胜任时)
+41 -9
View File
@@ -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({
+5 -2
View File
@@ -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,
+4 -3
View File
@@ -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) {
+36 -12
View File
@@ -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} 起已完成流程`
+21
View File
@@ -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>
+40 -8
View File
@@ -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>