diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d1dc38b..c9ac46f 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -141,6 +141,7 @@ model Organization { esignPerformanceEnabled Boolean @default(false) // 绩效考核电子签 esignDisciplinaryEnabled Boolean @default(false) // 违纪记录电子签 socialInsCutoffDay Int @default(15) // 社保/公积金截止日:每月该日前离职→截止月=离职月-1,该日后→截止月=离职月 + defaultGradeCoefficients Json? @default("{\"A\":1.2,\"B\":1.0,\"C\":0.8,\"D\":0.6}") // 缺省绩效系数(不选模板时使用) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -801,6 +802,7 @@ model PerformanceRecord { periodType String @default("MONTHLY") // MONTHLY/QUARTERLY/YEARLY score Float @default(0) // 考核得分 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 summary String? // 考核评语 improvementPlan String? // 改进计划(不胜任时) diff --git a/backend/src/routes/payroll2.routes.ts b/backend/src/routes/payroll2.routes.ts index bfa5112..dbeb8ea 100644 --- a/backend/src/routes/payroll2.routes.ts +++ b/backend/src/routes/payroll2.routes.ts @@ -324,13 +324,17 @@ router.get('/batches/:id', async (req: AuthRequest, res: Response, next: NextFun // 查询批次月份的绩效记录,附加到 entry 上 const perfRecords = await prisma.performanceRecord.findMany({ 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 = { 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 = (orgForCoeff?.defaultGradeCoefficients as Record) || { A: 1.2, B: 1.0, C: 0.8, D: 0.6 } const perfMap = new Map() for (const r of perfRecords) { 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 匹配) const performanceRecords = await prisma.performanceRecord.findMany({ 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() + // 查询关联的绩效模板(用于回退取 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>() + for (const t of templates) { + const coeffs: Record = {} + 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() for (const r of performanceRecords) { // 同一员工同月可能有多条记录,取最新一条(按创建时间倒序已由 DB 保证) 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: } } - // 绩效系数映射 - const gradeCoefficients: Record = { A: 1.2, B: 1.0, C: 0.8, D: 0.6 } + // 绩效系数:优先用记录中保存的 coefficient,回退到组织缺省配置,再回退到硬编码 + const orgForCoeff = await prisma.organization.findUnique({ where: { id: orgId }, select: { defaultGradeCoefficients: true } }) + const DEFAULT_GRADE_COEFFICIENTS: Record = (orgForCoeff?.defaultGradeCoefficients as Record) || { A: 1.2, B: 1.0, C: 0.8, D: 0.6 } let filled = 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 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 await prisma.batchEntry.update({ diff --git a/backend/src/routes/roster.routes.ts b/backend/src/routes/roster.routes.ts index 982d098..a675e75 100644 --- a/backend/src/routes/roster.routes.ts +++ b/backend/src/routes/roster.routes.ts @@ -1271,7 +1271,7 @@ router.get('/:employeeId/performance', authMiddleware, async (req: AuthRequest, router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest, res, next) => { 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({ where: { employeeId_period: { employeeId: req.params.employeeId, period } }, create: { @@ -1281,6 +1281,7 @@ router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest, periodType: periodType || 'MONTHLY', score: score || 0, grade: grade || 'B', + coefficient: coefficient != null ? Number(coefficient) : 1.0, result: result || 'QUALIFIED', summary, improvementPlan, @@ -1295,6 +1296,7 @@ router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest, periodType, score, grade, + coefficient: coefficient != null ? Number(coefficient) : undefined, result, summary, improvementPlan, @@ -1312,7 +1314,7 @@ router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest, router.put('/:employeeId/performance/:recordId', authMiddleware, async (req: AuthRequest, res, next) => { 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({ where: { id: req.params.recordId, orgId: req.user!.orgId }, }) @@ -1324,6 +1326,7 @@ router.put('/:employeeId/performance/:recordId', authMiddleware, async (req: Aut periodType, score, grade, + coefficient: coefficient != null ? Number(coefficient) : undefined, result, summary, improvementPlan, diff --git a/backend/src/routes/settings.routes.ts b/backend/src/routes/settings.routes.ts index 7fba1ab..834260c 100644 --- a/backend/src/routes/settings.routes.ts +++ b/backend/src/routes/settings.routes.ts @@ -28,7 +28,7 @@ router.get('/org', async (req: AuthRequest, res, next) => { try { const org = await prisma.organization.findUnique({ 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 }) } catch (err) { @@ -39,7 +39,7 @@ router.get('/org', async (req: AuthRequest, res, next) => { // 更新企业信息 router.put('/org', requireAdmin, async (req: AuthRequest, res, next) => { 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 } const updateData: any = {} if (name) updateData.name = name 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 (esignPayslipEnabled !== undefined) updateData.esignPayslipEnabled = esignPayslipEnabled if (esignOnboardingEnabled !== undefined) updateData.esignOnboardingEnabled = esignOnboardingEnabled + if (defaultGradeCoefficients !== undefined) updateData.defaultGradeCoefficients = defaultGradeCoefficients const org = await prisma.organization.update({ where: { id: req.user!.orgId }, 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 }) } catch (err) { diff --git a/backend/src/services/risk.service.ts b/backend/src/services/risk.service.ts index f792449..d38013f 100644 --- a/backend/src/services/risk.service.ts +++ b/backend/src/services/risk.service.ts @@ -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({ - where: { orgId, status: 'ACTIVE', paymentDay: { not: null } }, + where: { orgId, status: 'ACTIVE' }, }) for (const acc of accounts) { - const day = acc.paymentDay! + const day = acc.paymentDay || defaultCutoffDay if (today >= day) { const deadline = new Date(now.getFullYear(), now.getMonth(), day) const typeLabel = acc.type === 'SOCIAL' ? '社保' : '公积金' @@ -721,8 +723,9 @@ export async function getDashboardData(orgId: string) { 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 }, }), - prisma.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }), - prisma.housingFundConfig.findFirst({ where: { orgId, isCurrent: true } }), + // 社保/公积金配置:优先查 SocialYearStandard(新架构),回退到旧表 + 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({ 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.payslip.count({ where: { orgId, confirmedAt: null } }), prisma.payslip.count({ where: { orgId } }), - prisma.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }), - prisma.housingFundConfig.findFirst({ where: { orgId, isCurrent: true } }), + // 社保/公积金配置:优先查 SocialYearStandard(新架构),回退到旧表 + 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, + 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, prisma.riskItem.count({ where: { orgId, @@ -1519,13 +1523,18 @@ export async function getComplianceScore(orgId: string) { ? Math.max(0, 100 - Math.round((unconfirmedPayslips / totalPayslips) * 100)) : 100 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 = [ { key: 'contract', name: '合同管理', score: contractScore, todoCount: unsignedContracts + pendingSignContracts + expiringContracts }, { key: 'policy', name: '规章制度', score: policyScore, todoCount: policiesWithoutPublish }, { key: 'attendance', name: '考勤工时', score: attendanceScore, todoCount: attendanceUnconfirmed }, { 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( @@ -1679,10 +1688,17 @@ export async function getHealthCheck(orgId: string) { // democracyProgress 是 JSONB,用 status 字段判断未完成 prisma.policyDocument.count({ where: { orgId, status: { not: 'PUBLISHED' } } }), prisma.policyDocument.count({ where: { orgId } }), - prisma.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }), - prisma.housingFundConfig.findFirst({ where: { orgId, isCurrent: true } }), - // 社保记录按员工去重 - prisma.employeeSocialInsRecord.groupBy({ by: ['employeeId'], where: { orgId, endMonth: null }, _count: true }).then(r => r.length), + // 社保/公积金配置:优先查 SocialYearStandard(新架构),回退到旧表 + 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, + 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, + // 社保记录:计算有正式劳动合同但无社保记录的在职员工数(实习/劳务/外包等不缴社保) + 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.terminationRecord.count({ where: { orgId } }), @@ -1703,6 +1719,7 @@ export async function getHealthCheck(orgId: string) { key: 'contract', name: '合同管理', score: Math.max(0, 100 - unsignedContracts * 15 - pendingSignContracts * 10 - expiringContracts * 5), + todoCount: unsignedContracts + pendingSignContracts + expiringContracts, findings: [ unsignedContracts > 0 ? `${unsignedContracts} 名员工未签书面合同,存在双倍工资风险` : '全员已有合同记录', pendingSignContracts > 0 ? `${pendingSignContracts} 份合同待签署(已创建未完成签署)` : '', @@ -1720,6 +1737,7 @@ export async function getHealthCheck(orgId: string) { score: totalPayslips > 0 ? Math.max(0, 100 - Math.round((unconfirmedPayslips / totalPayslips) * 100)) : 100, + todoCount: unconfirmedPayslips, findings: [ totalPayslips > 0 ? `${unconfirmedPayslips}/${totalPayslips} 张工资条未确认` @@ -1737,6 +1755,9 @@ export async function getHealthCheck(orgId: string) { (housingConfig ? 30 : 0) + (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: [ socialConfig ? '社保配置已设置' : '未配置社保比例,建议尽快设置', housingConfig ? '公积金配置已设置' : '未配置公积金比例', @@ -1751,6 +1772,7 @@ export async function getHealthCheck(orgId: string) { key: 'attendance', name: '考勤加班', score: Math.max(0, 100 - overtimeExcessive * 10), + todoCount: overtimeExcessive, findings: [ overtimeExcessive > 0 ? `${overtimeExcessive} 条加班记录月超时36小时,存在违法风险` : '加班时长合规', attendanceRecords > 0 ? `已记录 ${attendanceRecords} 条考勤数据` : '暂无考勤记录,建议建立考勤制度', @@ -1766,6 +1788,7 @@ export async function getHealthCheck(orgId: string) { score: totalPolicies > 0 ? Math.max(0, 100 - policiesWithoutPublish * 12) : 60, + todoCount: policiesWithoutPublish, findings: [ totalPolicies === 0 ? '尚未上传任何规章制度,建议建立基础制度体系' : `已管理 ${totalPolicies} 项制度`, policiesWithoutPublish > 0 ? `${policiesWithoutPublish} 项制度未完成民主程序,仲裁中可能无效` : '所有制度已完成民主公示', @@ -1784,6 +1807,7 @@ export async function getHealthCheck(orgId: string) { (terminationsWithChecklist / Math.max(1, completedTerminations)) * 40 ) : 100, + todoCount: totalTerminations - completedTerminations, findings: [ totalTerminations > 0 ? `共 ${totalTerminations} 起解聘,${completedTerminations} 起已完成流程` diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index b1e1780..a6e58dc 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -108,6 +108,7 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data: esignTrainingEnabled: false, esignPerformanceEnabled: false, esignDisciplinaryEnabled: false, + defaultGradeCoefficients: { A: 1.2, B: 1.0, C: 0.8, D: 0.6 } as Record, }) useEffect(() => { @@ -125,6 +126,7 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data: esignTrainingEnabled: orgData.esignTrainingEnabled || false, esignPerformanceEnabled: orgData.esignPerformanceEnabled || false, esignDisciplinaryEnabled: orgData.esignDisciplinaryEnabled || false, + defaultGradeCoefficients: orgData.defaultGradeCoefficients || { A: 1.2, B: 1.0, C: 0.8, D: 0.6 }, }) } }, [orgData]) @@ -180,6 +182,25 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data: 天前在工作台提醒发薪 +
+ +

不选择绩效模板时,编辑绩效记录自动带出的等级系数。绩效工资 = 绩效工资基数 × 系数。

+
+ {(['A', 'B', 'C', 'D'] as const).map(g => ( +
+ + setForm({ ...form, defaultGradeCoefficients: { ...form.defaultGradeCoefficients, [g]: Number(e.target.value) } })} + className="text-sm" + /> +
+ ))} +
+
diff --git a/frontend/src/pages/roster/PerformanceInfo.tsx b/frontend/src/pages/roster/PerformanceInfo.tsx index aa53a63..a0dc567 100644 --- a/frontend/src/pages/roster/PerformanceInfo.tsx +++ b/frontend/src/pages/roster/PerformanceInfo.tsx @@ -1,6 +1,6 @@ import { useState } from "react" 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 Button from "../../components/ui/Button" 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[] }) { const queryClient = useQueryClient() const [showForm, setShowForm] = useState(false) - const [form, setForm] = useState({ period: '', periodType: 'MONTHLY' as 'MONTHLY' | 'QUARTERLY' | 'YEARLY', score: 80, grade: 'B', result: 'QUALIFIED', summary: '', improvementPlan: '', employeeAck: false, ackDate: '', reviewer: '', 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>({}) const { data: templates } = useQuery({ @@ -19,6 +19,12 @@ export default function PerformanceInfo({ employeeId, records }: { employeeId: s queryFn: () => rosterApi.performanceTemplates(), }) + // 获取组织缺省绩效系数配置 + const { data: orgData } = useQuery({ + queryKey: ['org-settings'], + queryFn: () => settingsApi.org(), + }) + const createMutation = useMutation({ mutationFn: (data: any) => rosterApi.performance(employeeId, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false); setDimensionScores({}) }, @@ -31,6 +37,24 @@ export default function PerformanceInfo({ employeeId, records }: { employeeId: s const resultMap: Record = { 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 = orgData?.defaultGradeCoefficients || { A: 1.2, B: 1.0, C: 0.8, D: 0.6 } + const DEFAULT_COEFFICIENTS: Record = (() => { + if (selectedTemplate?.gradeRules && Array.isArray(selectedTemplate.gradeRules)) { + const map: Record = { ...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 } => { 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 { 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 dimensions: any[] = selectedTemplate?.dimensions || [] + const handleGradeChange = (grade: string) => { + const newCoeff = DEFAULT_COEFFICIENTS[grade] ?? 1.0 + setForm({ ...form, grade, coefficient: newCoeff }) + } const handleDimensionChange = (name: string, score: number) => { const updated = { ...dimensionScores, [name]: score } @@ -58,7 +85,8 @@ export default function PerformanceInfo({ employeeId, records }: { employeeId: s return sum + (s / maxScore) * weight * 100 }, 0) 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 <>
handleScoreChange(Number(e.target.value))} />
- handleGradeChange(e.target.value)}>
)} +
+ setForm({ ...form, coefficient: Number(e.target.value) })} placeholder="默认 A=1.2, B=1.0, C=0.8, D=0.6" /> +

用于薪资计算:绩效工资 = 绩效工资基数 × 系数

+
setForm({ ...form, grade: e.target.value })}> + setForm({ ...form, coefficient: Number(e.target.value) })} + placeholder="默认 A=1.2, B=1.0, C=0.8, D=0.6,可手动调整" + /> +

用于薪资计算:绩效工资 = 绩效工资基数 × 系数。等级变更时自动填充默认值,可手动修改。

+
setGradeCoefficients({ ...gradeCoefficients, [g]: Number(e.target.value) })} + className="text-sm" + /> +
+ ))} + +
-