feat: 完善考核调研人员搜索与模板逻辑
This commit is contained in:
parent
beb2676163
commit
01c9af89a6
@ -71,7 +71,7 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
}
|
||||
|
||||
if (path[0] === 'departments') {
|
||||
return ok(await listPerformanceSurveyDepartments(session))
|
||||
return ok(await listPerformanceSurveyDepartments(session, url.searchParams))
|
||||
}
|
||||
|
||||
if (path[0] === 'surveys' && path.length === 1) {
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { pinyin } from 'pinyin-pro'
|
||||
import Header from '@/components/Header'
|
||||
|
||||
type ApiState<T> = {
|
||||
@ -44,14 +45,47 @@ type DepartmentOption = {
|
||||
type Template = {
|
||||
code: string
|
||||
name: string
|
||||
shortName?: string
|
||||
description: string
|
||||
surveyType: string
|
||||
weight: number
|
||||
weightLabel?: string
|
||||
maxTotal?: number
|
||||
achievementLabel?: string
|
||||
achievementSuffix?: string
|
||||
questions: Array<{ id: string; title: string; description?: string }>
|
||||
}
|
||||
|
||||
const API_BASE = '/api/performance-survey'
|
||||
|
||||
function normalizePickerSearch(value: unknown) {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s/_\-||,,;;>]+/g, '')
|
||||
}
|
||||
|
||||
function pickerSearchVariants(value: unknown) {
|
||||
const text = String(value ?? '').trim()
|
||||
if (!text) return []
|
||||
const variants = [normalizePickerSearch(text)]
|
||||
try {
|
||||
variants.push(
|
||||
normalizePickerSearch(pinyin(text, { toneType: 'none', type: 'array' }).join('')),
|
||||
normalizePickerSearch(pinyin(text, { pattern: 'first', toneType: 'none', type: 'array' }).join('')),
|
||||
)
|
||||
} catch {
|
||||
// 非中文或异常字符按原文匹配即可。
|
||||
}
|
||||
return Array.from(new Set(variants.filter(Boolean)))
|
||||
}
|
||||
|
||||
function matchesPickerSearch(keyword: string, values: unknown[]) {
|
||||
const normalizedKeyword = normalizePickerSearch(keyword)
|
||||
if (!normalizedKeyword) return true
|
||||
return values.some((value) => pickerSearchVariants(value).some((variant) => variant.includes(normalizedKeyword)))
|
||||
}
|
||||
|
||||
async function apiRequest<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
@ -180,6 +214,7 @@ function Dashboard() {
|
||||
|
||||
{surveys.loading && <Panel>加载中...</Panel>}
|
||||
{surveys.error && <ErrorPanel message={surveys.error} />}
|
||||
{surveys.data && <DashboardStats data={surveys.data} />}
|
||||
{surveys.data && tab === 'pending' && <PendingList items={surveys.data.pending || []} />}
|
||||
{surveys.data && tab === 'created' && (
|
||||
<CreatedList items={surveys.data.created || []} onRefresh={surveys.refresh} />
|
||||
@ -190,12 +225,37 @@ function Dashboard() {
|
||||
onChanged={async () => {
|
||||
await surveys.refresh()
|
||||
}}
|
||||
templateStats={surveys.data?.templateStats || []}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function DashboardStats({ data }: { data: any }) {
|
||||
const created = data.created || []
|
||||
const templateStats = data.templateStats || []
|
||||
const responseTotal = created.reduce((sum: number, item: any) => sum + Number(item.counts?.responses || 0), 0)
|
||||
const openTotal = created.filter((item: any) => item.status === 'open').length
|
||||
const items = [
|
||||
{ label: '问卷事项', value: created.length },
|
||||
{ label: '评价总数', value: responseTotal },
|
||||
{ label: '模板数量', value: templateStats.length },
|
||||
{ label: '开放问卷', value: openTotal },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="mb-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">{item.label}</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-gray-900 dark:text-white">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PendingList({ items }: { items: any[] }) {
|
||||
if (items.length === 0) {
|
||||
return <EmptyState title="暂无待评价" />
|
||||
@ -329,35 +389,71 @@ function MyResults({ items }: { items: any[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ManagePanel({ onChanged }: { onChanged: () => Promise<void> }) {
|
||||
function ManagePanel({ onChanged, templateStats }: { onChanged: () => Promise<void>; templateStats: any[] }) {
|
||||
const templates = useApi<Template[]>('/templates', [])
|
||||
const departments = useApi<DepartmentOption[]>('/departments', [])
|
||||
const departments = useApi<DepartmentOption[]>('/departments?purpose=evaluator', [])
|
||||
const notifications = useApi<any[]>('/admin/notifications?limit=30', [])
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.25fr)_minmax(340px,0.75fr)]">
|
||||
<CreateSurveyForm
|
||||
templates={templates.data || []}
|
||||
departments={departments.data || []}
|
||||
onCreated={async () => {
|
||||
setMessage('已创建考核调研')
|
||||
await onChanged()
|
||||
await notifications.refresh()
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-5">
|
||||
<AdminActions
|
||||
onMessage={setMessage}
|
||||
onNotificationsChanged={notifications.refresh}
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]">
|
||||
<CreateSurveyForm
|
||||
templates={templates.data || []}
|
||||
departments={departments.data || []}
|
||||
onCreated={async () => {
|
||||
setMessage('已创建考核调研')
|
||||
await onChanged()
|
||||
await notifications.refresh()
|
||||
}}
|
||||
/>
|
||||
{message && <Panel><span className="text-sm text-green-700 dark:text-green-300">{message}</span></Panel>}
|
||||
<TemplateStatsPanel items={templateStats} />
|
||||
</div>
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(340px,0.75fr)_minmax(0,1.25fr)]">
|
||||
<div className="space-y-5">
|
||||
<AdminActions
|
||||
onMessage={setMessage}
|
||||
onNotificationsChanged={notifications.refresh}
|
||||
/>
|
||||
{message && <Panel><span className="text-sm text-green-700 dark:text-green-300">{message}</span></Panel>}
|
||||
</div>
|
||||
<NotificationList state={notifications} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TemplateStatsPanel({ items }: { items: any[] }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">模板核算概览</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{items.map((item) => {
|
||||
const achievement = item.responseCount > 0 ? item.aggregate?.achievementScore : '暂无'
|
||||
const suffix = item.responseCount > 0 ? item.achievementSuffix || '' : ''
|
||||
return (
|
||||
<div key={item.code} className="grid grid-cols-1 items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900/40 sm:grid-cols-[minmax(0,1fr)_auto_auto] sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-gray-900 dark:text-white">{item.shortName || item.name}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{item.responseCount || 0} 份评价</div>
|
||||
</div>
|
||||
<div className="sm:w-24 sm:text-right">
|
||||
<div className="text-xl font-semibold text-gray-900 dark:text-white">{achievement}{suffix}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{item.achievementLabel || '得分'}</div>
|
||||
</div>
|
||||
<div className="sm:w-24 sm:text-right">
|
||||
<div className="text-xl font-semibold text-gray-900 dark:text-white">{item.responseCount > 0 ? item.aggregate?.weightedScore : '暂无'}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">权重得分 · {item.weightLabel || `${Math.round(Number(item.weight || 0) * 100)}%`}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{items.length === 0 && <div className="text-sm text-gray-500 dark:text-gray-400">暂无模板数据</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateSurveyForm({
|
||||
templates,
|
||||
departments,
|
||||
@ -369,7 +465,7 @@ function CreateSurveyForm({
|
||||
}) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [surveyKind, setSurveyKind] = useState('person_period')
|
||||
const [surveyKind, setSurveyKind] = useState('item_course')
|
||||
const [templateCode, setTemplateCode] = useState('')
|
||||
const [dueAt, setDueAt] = useState('')
|
||||
const [openNow, setOpenNow] = useState(true)
|
||||
@ -378,6 +474,7 @@ function CreateSurveyForm({
|
||||
const [evaluatorDepartments, setEvaluatorDepartments] = useState<string[]>([])
|
||||
const [itemName, setItemName] = useState('')
|
||||
const [targetItems, setTargetItems] = useState<Array<{ name: string }>>([])
|
||||
const [createdSurvey, setCreatedSurvey] = useState<any | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
@ -387,18 +484,24 @@ function CreateSurveyForm({
|
||||
const submit = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setCreatedSurvey(null)
|
||||
try {
|
||||
await apiRequest('/surveys', {
|
||||
const pendingItemName = itemName.trim()
|
||||
const targetItemsToSubmit = surveyKind === 'item_course' && pendingItemName
|
||||
? [...targetItems, { name: pendingItemName }]
|
||||
: targetItems
|
||||
const submitTitle = title.trim() || targetItemsToSubmit[0]?.name || ''
|
||||
const created = await apiRequest<any>('/surveys', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
title: submitTitle,
|
||||
description,
|
||||
surveyKind,
|
||||
templateCode: selectedTemplate,
|
||||
dueAt: dueAt || null,
|
||||
openNow,
|
||||
targetEmployeeIds: targetEmployees.map((employee) => employee.id),
|
||||
targetItems,
|
||||
targetItems: targetItemsToSubmit,
|
||||
evaluatorEmployeeIds: evaluatorEmployees.map((employee) => employee.id),
|
||||
evaluatorDepartmentNames: evaluatorDepartments,
|
||||
}),
|
||||
@ -410,6 +513,7 @@ function CreateSurveyForm({
|
||||
setEvaluatorDepartments([])
|
||||
setTargetItems([])
|
||||
setItemName('')
|
||||
setCreatedSurvey(created)
|
||||
await onCreated()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '创建失败')
|
||||
@ -418,9 +522,33 @@ function CreateSurveyForm({
|
||||
}
|
||||
}
|
||||
|
||||
const surveyLink = createdSurvey?.id
|
||||
? `${typeof window !== 'undefined' ? window.location.origin : ''}/performance-survey/survey/${createdSurvey.id}`
|
||||
: ''
|
||||
const qrLink = surveyLink
|
||||
? `https://api.qrserver.com/v1/create-qr-code/?size=220x220&margin=12&data=${encodeURIComponent(surveyLink)}`
|
||||
: ''
|
||||
|
||||
const copySurveyLink = async () => {
|
||||
if (!surveyLink) return
|
||||
await navigator.clipboard?.writeText(surveyLink)
|
||||
}
|
||||
|
||||
const downloadQr = async () => {
|
||||
if (!qrLink) return
|
||||
const response = await fetch(qrLink)
|
||||
const blob = await response.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${createdSurvey?.title || title || '考核调研'}-二维码.png`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">新建考核调研</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">创建考核问卷</h2>
|
||||
<div className="mt-4 grid gap-4">
|
||||
<TextInput label="标题" value={title} onChange={setTitle} />
|
||||
<label className="grid gap-1 text-sm">
|
||||
@ -446,16 +574,16 @@ function CreateSurveyForm({
|
||||
>
|
||||
{(activeTemplates.length > 0 ? activeTemplates : templates).map((template) => (
|
||||
<option key={template.code} value={template.code}>
|
||||
{template.name}
|
||||
{template.name} · {template.weightLabel || `${Math.round(template.weight * 100)}%`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<TextInput label="说明" value={description} onChange={setDescription} />
|
||||
<TextInput label="截止时间" value={dueAt} onChange={setDueAt} type="datetime-local" />
|
||||
<TextInput label={surveyKind === 'item_course' ? '发生时间' : '截止时间'} value={dueAt} onChange={setDueAt} type="datetime-local" />
|
||||
|
||||
{surveyKind === 'person_period' ? (
|
||||
<EmployeePicker title="被考核人" selected={targetEmployees} onChange={setTargetEmployees} />
|
||||
<EmployeePicker title="被考核人" purpose="target" selected={targetEmployees} onChange={setTargetEmployees} />
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
@ -485,7 +613,7 @@ function CreateSurveyForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EmployeePicker title="评价人" selected={evaluatorEmployees} onChange={setEvaluatorEmployees} />
|
||||
<EmployeePicker title="评价人" purpose="evaluator" selected={evaluatorEmployees} onChange={setEvaluatorEmployees} />
|
||||
<DepartmentPicker
|
||||
departments={departments}
|
||||
selected={evaluatorDepartments}
|
||||
@ -502,8 +630,29 @@ function CreateSurveyForm({
|
||||
onClick={submit}
|
||||
className="rounded-lg bg-gray-900 px-4 py-2 text-white hover:bg-gray-700 disabled:opacity-50 dark:bg-white dark:text-gray-900"
|
||||
>
|
||||
{saving ? '创建中...' : '创建考核调研'}
|
||||
{saving ? '创建中...' : '生成问卷二维码'}
|
||||
</button>
|
||||
{createdSurvey && surveyLink && (
|
||||
<div className="rounded-lg border border-blue-100 bg-blue-50 p-4 dark:border-blue-900/50 dark:bg-blue-950/30">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center">
|
||||
{/* 二维码来自第三方生成服务,无法走本项目的 Next Image 域名白名单。 */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{qrLink && <img src={qrLink} alt="问卷二维码" className="h-28 w-28 rounded-md bg-white p-1" />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-gray-900 dark:text-white">问卷已生成</div>
|
||||
<div className="mt-1 break-all text-sm text-gray-600 dark:text-gray-300">{surveyLink}</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button type="button" onClick={copySurveyLink} className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200">
|
||||
复制链接
|
||||
</button>
|
||||
<button type="button" onClick={downloadQr} className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200">
|
||||
保存二维码
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -511,52 +660,98 @@ function CreateSurveyForm({
|
||||
|
||||
function EmployeePicker({
|
||||
title,
|
||||
purpose,
|
||||
selected,
|
||||
onChange,
|
||||
}: {
|
||||
title: string
|
||||
purpose?: 'target' | 'evaluator'
|
||||
selected: Employee[]
|
||||
onChange: (employees: Employee[]) => void
|
||||
}) {
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [items, setItems] = useState<Employee[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const pickerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(async () => {
|
||||
if (!keyword.trim()) {
|
||||
setItems([])
|
||||
setError(null)
|
||||
setLoading(false)
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setOpen(true)
|
||||
try {
|
||||
const data = await apiRequest<Employee[]>(`/employees?search=${encodeURIComponent(keyword)}&limit=20`)
|
||||
const params = new URLSearchParams({
|
||||
search: keyword,
|
||||
limit: '50',
|
||||
...(purpose ? { purpose } : {}),
|
||||
})
|
||||
const data = await apiRequest<Employee[]>(`/employees?${params.toString()}`)
|
||||
setItems(data)
|
||||
} catch (err) {
|
||||
setItems([])
|
||||
setError(err instanceof Error ? err.message : '搜索失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, 250)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [keyword])
|
||||
}, [keyword, purpose])
|
||||
|
||||
useEffect(() => {
|
||||
const closeWhenOutside = (event: MouseEvent) => {
|
||||
if (!pickerRef.current?.contains(event.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', closeWhenOutside)
|
||||
return () => document.removeEventListener('mousedown', closeWhenOutside)
|
||||
}, [])
|
||||
|
||||
const selectedIds = new Set(selected.map((employee) => employee.id))
|
||||
const selectEmployee = (employee: Employee) => {
|
||||
if (selectedIds.has(employee.id)) return
|
||||
onChange([...selected, employee])
|
||||
setError(null)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div ref={pickerRef}>
|
||||
<label className="grid gap-1 text-sm">
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">{title}</span>
|
||||
<input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
onFocus={() => {
|
||||
if (keyword.trim()) setOpen(true)
|
||||
}}
|
||||
onChange={(event) => {
|
||||
setKeyword(event.target.value)
|
||||
if (event.target.value.trim()) setOpen(true)
|
||||
}}
|
||||
placeholder="搜索花名、姓名、工号、部门、职级"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') setOpen(false)
|
||||
}}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-900 dark:text-white"
|
||||
/>
|
||||
</label>
|
||||
{keyword && (
|
||||
{open && keyword && (
|
||||
<div className="mt-2 max-h-56 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
{loading ? (
|
||||
<div className="px-3 py-2 text-sm text-gray-500">搜索中...</div>
|
||||
) : error ? (
|
||||
<div className="px-3 py-2 text-sm text-red-600">{error}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-gray-500">无匹配人员</div>
|
||||
) : (
|
||||
@ -565,7 +760,7 @@ function EmployeePicker({
|
||||
key={employee.id}
|
||||
type="button"
|
||||
disabled={selectedIds.has(employee.id)}
|
||||
onClick={() => onChange([...selected, employee])}
|
||||
onClick={() => selectEmployee(employee)}
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 disabled:opacity-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{employee.displayName}</div>
|
||||
@ -640,8 +835,8 @@ function DepartmentPicker({
|
||||
department.departmentName,
|
||||
department.departmentPath,
|
||||
department.fullPath,
|
||||
].filter(Boolean).join(' ').toLowerCase()
|
||||
if (!haystack.includes(normalizedKeyword)) return
|
||||
].filter(Boolean)
|
||||
if (!matchesPickerSearch(normalizedKeyword, haystack)) return
|
||||
|
||||
let current: DepartmentOption | undefined = department
|
||||
while (current) {
|
||||
|
||||
55
lib/__tests__/performance-survey-templates.test.ts
Normal file
55
lib/__tests__/performance-survey-templates.test.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import {
|
||||
DEFAULT_PERFORMANCE_SURVEY_TEMPLATES,
|
||||
calculateAggregateScore,
|
||||
calculateResponseScores,
|
||||
} from '@/lib/performance-survey-templates'
|
||||
|
||||
describe('performance survey templates', () => {
|
||||
test('course share template uses the legacy 100-point and 30% weighted scoring rule', () => {
|
||||
const template = DEFAULT_PERFORMANCE_SURVEY_TEMPLATES.find((item) => item.code === 'course_share')!
|
||||
const scores = Object.fromEntries(template.questions.map((question) => [question.id, 20]))
|
||||
|
||||
const result = calculateResponseScores(template, scores)
|
||||
|
||||
expect(template.questions).toHaveLength(5)
|
||||
expect(template.maxTotal).toBe(100)
|
||||
expect(template.weight).toBe(0.3)
|
||||
expect(result.totalScore).toBe(100)
|
||||
expect(result.kpiScore).toBe(120)
|
||||
expect(result.weightedScore).toBe(36)
|
||||
})
|
||||
|
||||
test('person-period templates use the legacy 120-point and 20% weighted scoring rule', () => {
|
||||
const template = DEFAULT_PERFORMANCE_SURVEY_TEMPLATES.find((item) => item.code === 'cross_department')!
|
||||
const scores = Object.fromEntries(template.questions.map((question) => [question.id, 20]))
|
||||
|
||||
const result = calculateResponseScores(template, scores)
|
||||
|
||||
expect(template.questions).toHaveLength(6)
|
||||
expect(template.maxTotal).toBe(120)
|
||||
expect(template.weight).toBe(0.2)
|
||||
expect(result.totalScore).toBe(120)
|
||||
expect(result.kpiScore).toBe(120)
|
||||
expect(result.weightedScore).toBe(24)
|
||||
})
|
||||
|
||||
test('aggregate scoring drops one highest and one lowest response when there are at least three', () => {
|
||||
const template = DEFAULT_PERFORMANCE_SURVEY_TEMPLATES.find((item) => item.code === 'cross_department')!
|
||||
|
||||
const result = calculateAggregateScore(
|
||||
[
|
||||
{ totalScore: 60, createdAt: '2026-07-04T00:00:00.000Z' },
|
||||
{ totalScore: 100, createdAt: '2026-07-04T00:01:00.000Z' },
|
||||
{ totalScore: 120, createdAt: '2026-07-04T00:02:00.000Z' },
|
||||
],
|
||||
template.scoreBands,
|
||||
template.weight,
|
||||
)
|
||||
|
||||
expect(result.droppedHighestLowest).toBe(true)
|
||||
expect(result.effectiveCount).toBe(1)
|
||||
expect(result.achievementScore).toBe(100)
|
||||
expect(result.kpiScore).toBe(100)
|
||||
expect(result.weightedScore).toBe(20)
|
||||
})
|
||||
})
|
||||
@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { Session } from 'next-auth'
|
||||
import { pinyin } from 'pinyin-pro'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
calculateAggregateScore,
|
||||
@ -58,6 +59,34 @@ function clean(value: unknown) {
|
||||
return normalized.length > 0 ? normalized : null
|
||||
}
|
||||
|
||||
function normalizeSearchText(value: unknown) {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s/_\-||,,;;>]+/g, '')
|
||||
}
|
||||
|
||||
function searchVariants(value: unknown) {
|
||||
const text = String(value ?? '').trim()
|
||||
if (!text) return []
|
||||
const variants = [normalizeSearchText(text)]
|
||||
try {
|
||||
variants.push(
|
||||
normalizeSearchText(pinyin(text, { toneType: 'none', type: 'array' }).join('')),
|
||||
normalizeSearchText(pinyin(text, { pattern: 'first', toneType: 'none', type: 'array' }).join('')),
|
||||
)
|
||||
} catch {
|
||||
// 非中文或异常字符按原文匹配即可。
|
||||
}
|
||||
return Array.from(new Set(variants.filter(Boolean)))
|
||||
}
|
||||
|
||||
function matchesSearchKeyword(keyword: string, values: unknown[]) {
|
||||
const normalizedKeyword = normalizeSearchText(keyword)
|
||||
if (!normalizedKeyword) return true
|
||||
return values.some((value) => searchVariants(value).some((variant) => variant.includes(normalizedKeyword)))
|
||||
}
|
||||
|
||||
function normalizeList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return Array.from(new Set(value.map((item) => clean(item)).filter((item): item is string => Boolean(item))))
|
||||
@ -345,55 +374,174 @@ export async function searchPerformanceSurveyEmployees(session: Session | null,
|
||||
const search = clean(params.get('search')) || ''
|
||||
const department = clean(params.get('department'))
|
||||
const limit = Math.max(1, Math.min(Number(params.get('limit') || 50), 200))
|
||||
const isEvaluatorSearch = clean(params.get('purpose')) === 'evaluator'
|
||||
|
||||
const accountWhere = await getPerformanceSurveyAccountProfileWhere()
|
||||
const and: any[] = [accountWhere]
|
||||
if (department) {
|
||||
const departmentClauses = getDepartmentSelectionWhere([department])
|
||||
if (departmentClauses.length === 1) {
|
||||
and.push(departmentClauses[0])
|
||||
} else if (departmentClauses.length > 1) {
|
||||
and.push({ OR: departmentClauses })
|
||||
const buildWhere = async (includeKeyword: boolean) => {
|
||||
const accountWhere = await getPerformanceSurveyAccountProfileWhere()
|
||||
const and: any[] = [accountWhere]
|
||||
if (department) {
|
||||
const departmentClauses = getDepartmentSelectionWhere([department])
|
||||
if (departmentClauses.length === 1) {
|
||||
and.push(departmentClauses[0])
|
||||
} else if (departmentClauses.length > 1) {
|
||||
and.push({ OR: departmentClauses })
|
||||
}
|
||||
}
|
||||
if (!ctx.isSystemAdmin && ctx.scopeDepartment && !isEvaluatorSearch) {
|
||||
and.push(getScopeDepartmentWhere(ctx.scopeDepartment))
|
||||
}
|
||||
if (includeKeyword && search) {
|
||||
and.push({ OR: [
|
||||
{ displayName: { contains: search, mode: 'insensitive' } },
|
||||
{ flowerName: { contains: search, mode: 'insensitive' } },
|
||||
{ realName: { contains: search, mode: 'insensitive' } },
|
||||
{ jobNumber: { contains: search, mode: 'insensitive' } },
|
||||
{ dingEmail: { contains: search, mode: 'insensitive' } },
|
||||
{ departmentName: { contains: search, mode: 'insensitive' } },
|
||||
{ departmentPath: { contains: search, mode: 'insensitive' } },
|
||||
{ positionTitle: { contains: search, mode: 'insensitive' } },
|
||||
{ positionLevel: { contains: search, mode: 'insensitive' } },
|
||||
{ levelType: { contains: search, mode: 'insensitive' } },
|
||||
{ managerName: { contains: search, mode: 'insensitive' } },
|
||||
] })
|
||||
}
|
||||
return { AND: and } as any
|
||||
}
|
||||
if (!ctx.isSystemAdmin && ctx.scopeDepartment) {
|
||||
and.push(getScopeDepartmentWhere(ctx.scopeDepartment))
|
||||
}
|
||||
if (search) {
|
||||
and.push({ OR: [
|
||||
{ displayName: { contains: search, mode: 'insensitive' } },
|
||||
{ flowerName: { contains: search, mode: 'insensitive' } },
|
||||
{ realName: { contains: search, mode: 'insensitive' } },
|
||||
{ jobNumber: { contains: search, mode: 'insensitive' } },
|
||||
{ dingEmail: { contains: search, mode: 'insensitive' } },
|
||||
{ departmentName: { contains: search, mode: 'insensitive' } },
|
||||
{ departmentPath: { contains: search, mode: 'insensitive' } },
|
||||
{ positionLevel: { contains: search, mode: 'insensitive' } },
|
||||
{ levelType: { contains: search, mode: 'insensitive' } },
|
||||
{ managerName: { contains: search, mode: 'insensitive' } },
|
||||
] })
|
||||
}
|
||||
const where: any = { AND: and }
|
||||
|
||||
const employees = await prisma.performanceSurveyEmployeeProfile.findMany({
|
||||
where,
|
||||
const queryEmployees = async () => prisma.performanceSurveyEmployeeProfile.findMany({
|
||||
where: await buildWhere(true),
|
||||
orderBy: [{ departmentName: 'asc' }, { displayName: 'asc' }],
|
||||
take: limit,
|
||||
})
|
||||
|
||||
let employees = await queryEmployees()
|
||||
|
||||
if (search && employees.length < limit) {
|
||||
const candidates = await prisma.performanceSurveyEmployeeProfile.findMany({
|
||||
where: await buildWhere(false),
|
||||
orderBy: [{ departmentName: 'asc' }, { displayName: 'asc' }],
|
||||
take: Math.min(5000, Math.max(1000, limit * 20)),
|
||||
})
|
||||
const existingIds = new Set(employees.map((employee) => employee.id))
|
||||
const pinyinMatches = candidates.filter((employee) => {
|
||||
if (existingIds.has(employee.id)) return false
|
||||
return matchesSearchKeyword(search, [
|
||||
employee.displayName,
|
||||
employee.flowerName,
|
||||
employee.realName,
|
||||
employee.jobNumber,
|
||||
employee.dingEmail,
|
||||
employee.departmentName,
|
||||
employee.departmentPath,
|
||||
employee.positionTitle,
|
||||
employee.positionLevel,
|
||||
employee.levelType,
|
||||
employee.managerName,
|
||||
])
|
||||
})
|
||||
employees = [...employees, ...pinyinMatches].slice(0, limit)
|
||||
}
|
||||
|
||||
if (employees.length === 0 && search) {
|
||||
let matchingUsers = await prisma.user.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ username: { contains: search, mode: 'insensitive' } },
|
||||
{ realName: { contains: search, mode: 'insensitive' } },
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
{ dingtalkUserId: { contains: search, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
{ archivedAt: null },
|
||||
{
|
||||
NOT: [
|
||||
{ status: 'archived' },
|
||||
{ status: 'suspended' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
realName: true,
|
||||
email: true,
|
||||
dingtalkUserId: true,
|
||||
department: true,
|
||||
},
|
||||
take: 10,
|
||||
})
|
||||
|
||||
if (matchingUsers.length < 10) {
|
||||
const existingUserIds = new Set(matchingUsers.map((user) => user.id))
|
||||
const candidates = await prisma.user.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{ archivedAt: null },
|
||||
{
|
||||
NOT: [
|
||||
{ status: 'archived' },
|
||||
{ status: 'suspended' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
realName: true,
|
||||
email: true,
|
||||
dingtalkUserId: true,
|
||||
department: true,
|
||||
},
|
||||
take: Math.min(5000, Math.max(1000, limit * 20)),
|
||||
})
|
||||
const pinyinUsers = candidates.filter((user) => {
|
||||
if (existingUserIds.has(user.id)) return false
|
||||
return matchesSearchKeyword(search, [
|
||||
user.username,
|
||||
user.realName,
|
||||
user.email,
|
||||
user.dingtalkUserId,
|
||||
user.department,
|
||||
])
|
||||
})
|
||||
matchingUsers = [...matchingUsers, ...pinyinUsers].slice(0, 10)
|
||||
}
|
||||
|
||||
for (const user of matchingUsers) {
|
||||
await ensurePerformanceSurveyEmployeeProfileForUser({
|
||||
userId: user.id,
|
||||
dingtalkUserId: user.dingtalkUserId,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
realName: user.realName,
|
||||
displayName: getDisplayName(user),
|
||||
department: user.department,
|
||||
})
|
||||
}
|
||||
|
||||
if (matchingUsers.length > 0) {
|
||||
employees = await queryEmployees()
|
||||
}
|
||||
}
|
||||
|
||||
return employees.map(serializeEmployee)
|
||||
}
|
||||
|
||||
export async function listPerformanceSurveyDepartments(session: Session | null) {
|
||||
export async function listPerformanceSurveyDepartments(session: Session | null, params?: URLSearchParams) {
|
||||
const ctx = await getPerformanceSurveyContext(session)
|
||||
ensureManager(ctx)
|
||||
const isEvaluatorPicker = clean(params?.get('purpose')) === 'evaluator'
|
||||
|
||||
const accountWhere = await getPerformanceSurveyAccountProfileWhere()
|
||||
const and: any[] = [
|
||||
accountWhere,
|
||||
{ departmentName: { not: null } },
|
||||
]
|
||||
if (!ctx.isSystemAdmin && ctx.scopeDepartment) {
|
||||
if (!ctx.isSystemAdmin && ctx.scopeDepartment && !isEvaluatorPicker) {
|
||||
and.push(getScopeDepartmentWhere(ctx.scopeDepartment))
|
||||
}
|
||||
|
||||
@ -494,6 +642,26 @@ export async function listPerformanceSurveys(session: Session | null) {
|
||||
take: 100,
|
||||
})
|
||||
|
||||
const templates = await listPerformanceSurveyTemplates()
|
||||
const scopedResponses = await prisma.performanceSurveyResponse.findMany({
|
||||
where: {
|
||||
survey: managerWhere as any,
|
||||
},
|
||||
include: {
|
||||
survey: {
|
||||
select: { templateCode: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5000,
|
||||
})
|
||||
const responsesByTemplate = new Map<string, typeof scopedResponses>()
|
||||
for (const response of scopedResponses) {
|
||||
const list = responsesByTemplate.get(response.survey.templateCode) || []
|
||||
list.push(response)
|
||||
responsesByTemplate.set(response.survey.templateCode, list)
|
||||
}
|
||||
|
||||
const resultTargets = ctx.employee
|
||||
? await prisma.performanceSurveyTarget.findMany({
|
||||
where: {
|
||||
@ -540,6 +708,28 @@ export async function listPerformanceSurveys(session: Session | null) {
|
||||
targetName: target.targetName,
|
||||
closedAt: target.survey.closedAt,
|
||||
})),
|
||||
templateStats: templates.map((template) => {
|
||||
const responses = responsesByTemplate.get(template.code) || []
|
||||
const aggregate = calculateAggregateScore(
|
||||
responses.map((response) => ({
|
||||
totalScore: response.totalScore,
|
||||
createdAt: response.createdAt,
|
||||
})),
|
||||
template.scoreBands,
|
||||
template.weight,
|
||||
)
|
||||
return {
|
||||
code: template.code,
|
||||
name: template.name,
|
||||
shortName: template.shortName,
|
||||
weight: template.weight,
|
||||
weightLabel: template.weightLabel,
|
||||
achievementLabel: template.achievementLabel,
|
||||
achievementSuffix: template.achievementSuffix,
|
||||
responseCount: responses.length,
|
||||
aggregate,
|
||||
}
|
||||
}),
|
||||
permissions: {
|
||||
isSystemAdmin: ctx.isSystemAdmin,
|
||||
isDepartmentAdmin: ctx.isDepartmentAdmin,
|
||||
@ -1133,9 +1323,6 @@ async function resolveSurveyEvaluators(body: any, ctx: UserContext) {
|
||||
await getPerformanceSurveyAccountProfileWhere(),
|
||||
{ OR: or },
|
||||
]
|
||||
if (!ctx.isSystemAdmin && ctx.scopeDepartment) {
|
||||
and.push(getScopeDepartmentWhere(ctx.scopeDepartment))
|
||||
}
|
||||
const where: any = { AND: and }
|
||||
|
||||
const employees = or.length > 0
|
||||
|
||||
@ -9,15 +9,23 @@ export type PerformanceSurveyQuestion = {
|
||||
export type PerformanceSurveyScoreBand = {
|
||||
min: number
|
||||
max: number
|
||||
score: number
|
||||
score?: number
|
||||
outMin?: number
|
||||
outMax?: number
|
||||
label?: string
|
||||
}
|
||||
|
||||
export type PerformanceSurveyTemplateShape = {
|
||||
code: string
|
||||
name: string
|
||||
shortName?: string
|
||||
description: string
|
||||
surveyType: string
|
||||
weight: number
|
||||
weightLabel?: string
|
||||
maxTotal?: number
|
||||
achievementLabel?: string
|
||||
achievementSuffix?: string
|
||||
questions: PerformanceSurveyQuestion[]
|
||||
scoreBands: PerformanceSurveyScoreBand[]
|
||||
}
|
||||
@ -25,59 +33,79 @@ export type PerformanceSurveyTemplateShape = {
|
||||
export const DEFAULT_PERFORMANCE_SURVEY_TEMPLATES: PerformanceSurveyTemplateShape[] = [
|
||||
{
|
||||
code: 'course_share',
|
||||
name: '课程/分享调研',
|
||||
name: '课程分享满意度调研',
|
||||
shortName: '课程分享',
|
||||
description: '适用于课程培训、经验分享、内部活动后的满意度与效果反馈。',
|
||||
surveyType: 'item_course',
|
||||
weight: 1,
|
||||
weight: 0.3,
|
||||
weightLabel: '30%',
|
||||
maxTotal: 100,
|
||||
achievementLabel: '满意度',
|
||||
achievementSuffix: '%',
|
||||
questions: [
|
||||
{ id: 'content_value', title: '内容价值', description: '内容对工作是否有帮助' },
|
||||
{ id: 'clarity', title: '表达清晰度', description: '讲解逻辑、重点和案例是否清楚' },
|
||||
{ id: 'practicality', title: '可落地程度', description: '是否能转化为实际行动' },
|
||||
{ id: 'interaction', title: '互动与答疑', description: '现场互动和问题回应情况' },
|
||||
{ id: 'usefulness', title: '内容实用度', description: '听完能用上吗?' },
|
||||
{ id: 'insight', title: '见解深度', description: '有没有独到洞察?' },
|
||||
{ id: 'clarity', title: '结构清晰度', description: '听得懂、跟得上吗?' },
|
||||
{ id: 'caseQuality', title: '案例质量', description: '有真实例支撑吗?' },
|
||||
{ id: 'interaction', title: '互动参与感', description: '有参与感还是全程被动?' },
|
||||
],
|
||||
scoreBands: [
|
||||
{ min: 0, max: 12, score: 60 },
|
||||
{ min: 12, max: 15, score: 75 },
|
||||
{ min: 15, max: 18, score: 90 },
|
||||
{ min: 18, max: 20, score: 100 },
|
||||
{ min: 95, max: 100, outMin: 100, outMax: 120, label: '满意度 95% 以上,对应 120-100 分' },
|
||||
{ min: 89, max: 94, outMin: 85, outMax: 100, label: '满意度 94%-89%,对应 100-85 分' },
|
||||
{ min: 81, max: 88, outMin: 70, outMax: 84, label: '满意度 88%-81%,对应 84-70 分' },
|
||||
{ min: 77, max: 80, outMin: 60, outMax: 69, label: '满意度 80%-77%,对应 69-60 分' },
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'cross_department',
|
||||
name: '跨部门协作考核',
|
||||
name: '跨部门协同满意度调研',
|
||||
shortName: '跨部门协同',
|
||||
description: '适用于跨部门项目、流程配合、协同响应等人员周期考核。',
|
||||
surveyType: 'person_period',
|
||||
weight: 1,
|
||||
weight: 0.2,
|
||||
weightLabel: '20%',
|
||||
maxTotal: 120,
|
||||
achievementLabel: '协作得分',
|
||||
achievementSuffix: '',
|
||||
questions: [
|
||||
{ id: 'response_speed', title: '响应速度', description: '需求响应是否及时' },
|
||||
{ id: 'delivery_quality', title: '交付质量', description: '交付结果是否稳定可靠' },
|
||||
{ id: 'communication', title: '沟通协同', description: '沟通是否主动、清晰、闭环' },
|
||||
{ id: 'ownership', title: '责任意识', description: '是否能主动推进问题解决' },
|
||||
{ id: 'requirementClarity', title: '需求清晰度', description: '目标、需求与验收口径是否清晰?' },
|
||||
{ id: 'deliveryStandard', title: '交付规范性', description: '交付物是否规范、完整、可复用?' },
|
||||
{ id: 'responseSpeed', title: '响应时效性', description: '响应是否及时,关键节点是否不拖延?' },
|
||||
{ id: 'collaborationFit', title: '协作配合度', description: '跨部门配合是否主动、顺畅?' },
|
||||
{ id: 'communicationEffect', title: '沟通有效性', description: '信息传递是否准确、减少反复?' },
|
||||
{ id: 'processControl', title: '过程可控性', description: '过程是否有预警、有节奏、有闭环?' },
|
||||
],
|
||||
scoreBands: [
|
||||
{ min: 0, max: 12, score: 60 },
|
||||
{ min: 12, max: 15, score: 75 },
|
||||
{ min: 15, max: 18, score: 90 },
|
||||
{ min: 18, max: 20, score: 100 },
|
||||
{ min: 100, max: 120, outMin: 100, outMax: 120, label: '非常满意,并给予高度评价,对应 120-100 分' },
|
||||
{ min: 85, max: 99.9, outMin: 85, outMax: 100, label: '满意,基本达到标准,对应 100-85 分' },
|
||||
{ min: 70, max: 84.9, outMin: 70, outMax: 84, label: '评价尚可,还有进步空间,对应 84-70 分' },
|
||||
{ min: 60, max: 69.9, outMin: 60, outMax: 69, label: '评价一般,有较大进步空间,对应 69-60 分' },
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'work_cooperation',
|
||||
name: '日常工作配合考核',
|
||||
name: '工作配合度满意度调研',
|
||||
shortName: '工作配合度',
|
||||
description: '适用于常规工作协作、服务支持、团队内互评。',
|
||||
surveyType: 'person_period',
|
||||
weight: 1,
|
||||
weight: 0.2,
|
||||
weightLabel: '20%',
|
||||
maxTotal: 120,
|
||||
achievementLabel: '配合度得分',
|
||||
achievementSuffix: '',
|
||||
questions: [
|
||||
{ id: 'professionalism', title: '专业能力', description: '专业判断和处理问题能力' },
|
||||
{ id: 'reliability', title: '可靠程度', description: '承诺事项是否稳定完成' },
|
||||
{ id: 'service_attitude', title: '服务态度', description: '协作态度和服务意识' },
|
||||
{ id: 'improvement', title: '改进意识', description: '是否持续优化工作方式' },
|
||||
{ id: 'executionDepth', title: '执行落实度', description: '交办的事放心吗?' },
|
||||
{ id: 'ownership', title: '主动担责', description: '推一步走一步还是自己跑?' },
|
||||
{ id: 'reportCommunication', title: '汇报沟通', description: '进度看得见吗?出事早知道吗?' },
|
||||
{ id: 'adaptability', title: '灵活应变', description: '变了能不能跟着转?' },
|
||||
{ id: 'teamAlignment', title: '团队补位', description: '别人忙不过来会搭把手吗?' },
|
||||
{ id: 'emotionalStability', title: '情绪稳定度', description: '压力下解决问题还是制造情绪?' },
|
||||
],
|
||||
scoreBands: [
|
||||
{ min: 0, max: 12, score: 60 },
|
||||
{ min: 12, max: 15, score: 75 },
|
||||
{ min: 15, max: 18, score: 90 },
|
||||
{ min: 18, max: 20, score: 100 },
|
||||
{ min: 100, max: 120, outMin: 100, outMax: 120, label: '非常满意,并给予高度评价,对应 120-100 分' },
|
||||
{ min: 85, max: 99.9, outMin: 85, outMax: 100, label: '满意,基本达到标准,对应 100-85 分' },
|
||||
{ min: 70, max: 84.9, outMin: 70, outMax: 84, label: '评价尚可,还有进步空间,对应 84-70 分' },
|
||||
{ min: 60, max: 69.9, outMin: 60, outMax: 69, label: '评价一般,有较大进步空间,对应 69-60 分' },
|
||||
],
|
||||
},
|
||||
]
|
||||
@ -117,14 +145,14 @@ export async function listPerformanceSurveyTemplates() {
|
||||
orderBy: [{ surveyType: 'asc' }, { createdAt: 'asc' }],
|
||||
})
|
||||
|
||||
return templates.map((template) => ({
|
||||
return templates.map((template) => withDefaultTemplateMeta({
|
||||
code: template.code,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
description: template.description || '',
|
||||
surveyType: template.surveyType,
|
||||
weight: template.weight,
|
||||
questions: template.questions,
|
||||
scoreBands: template.scoreBands,
|
||||
questions: template.questions as PerformanceSurveyQuestion[],
|
||||
scoreBands: template.scoreBands as PerformanceSurveyScoreBand[],
|
||||
}))
|
||||
}
|
||||
|
||||
@ -139,7 +167,7 @@ export async function getPerformanceSurveyTemplate(code: string) {
|
||||
throw new Error('考核调研模板不存在')
|
||||
}
|
||||
|
||||
return {
|
||||
return withDefaultTemplateMeta({
|
||||
code: template.code,
|
||||
name: template.name,
|
||||
description: template.description || '',
|
||||
@ -147,6 +175,18 @@ export async function getPerformanceSurveyTemplate(code: string) {
|
||||
weight: template.weight,
|
||||
questions: template.questions as PerformanceSurveyQuestion[],
|
||||
scoreBands: template.scoreBands as PerformanceSurveyScoreBand[],
|
||||
})
|
||||
}
|
||||
|
||||
function withDefaultTemplateMeta(template: PerformanceSurveyTemplateShape): PerformanceSurveyTemplateShape {
|
||||
const matchedDefault = DEFAULT_PERFORMANCE_SURVEY_TEMPLATES.find((item) => item.code === template.code)
|
||||
return {
|
||||
...template,
|
||||
shortName: matchedDefault?.shortName,
|
||||
weightLabel: matchedDefault?.weightLabel || `${Math.round(Number(template.weight || 0) * 100)}%`,
|
||||
maxTotal: matchedDefault?.maxTotal,
|
||||
achievementLabel: matchedDefault?.achievementLabel,
|
||||
achievementSuffix: matchedDefault?.achievementSuffix,
|
||||
}
|
||||
}
|
||||
|
||||
@ -164,7 +204,7 @@ export function calculateResponseScores(
|
||||
|
||||
const totalScore =
|
||||
questionScores.length > 0
|
||||
? questionScores.reduce((sum, score) => sum + score, 0) / questionScores.length
|
||||
? questionScores.reduce((sum, score) => sum + score, 0)
|
||||
: 0
|
||||
|
||||
const kpiScore = mapAchievementToKpiScore(totalScore, template.scoreBands)
|
||||
@ -213,8 +253,14 @@ function mapAchievementToKpiScore(value: number, bands: PerformanceSurveyScoreBa
|
||||
const normalizedBands = [...bands].sort((left, right) => left.min - right.min)
|
||||
const matched = normalizedBands.find((band) => value >= band.min && value <= band.max)
|
||||
if (!matched) {
|
||||
if (value < normalizedBands[0]?.min) return normalizedBands[0]?.score || 0
|
||||
return normalizedBands[normalizedBands.length - 1]?.score || 0
|
||||
if (value < normalizedBands[0]?.min) return 0
|
||||
const last = normalizedBands[normalizedBands.length - 1]
|
||||
return last?.outMax ?? last?.score ?? 0
|
||||
}
|
||||
|
||||
if (matched.outMin != null && matched.outMax != null) {
|
||||
const ratio = (value - matched.min) / (matched.max - matched.min)
|
||||
return matched.outMin + ratio * (matched.outMax - matched.outMin)
|
||||
}
|
||||
|
||||
const previous = normalizedBands
|
||||
@ -222,11 +268,11 @@ function mapAchievementToKpiScore(value: number, bands: PerformanceSurveyScoreBa
|
||||
.sort((left, right) => right.max - left.max)[0]
|
||||
|
||||
if (!previous || matched.max === matched.min) {
|
||||
return matched.score
|
||||
return matched.score ?? 0
|
||||
}
|
||||
|
||||
const ratio = (value - matched.min) / (matched.max - matched.min)
|
||||
return previous.score + ratio * (matched.score - previous.score)
|
||||
return (previous.score ?? 0) + ratio * ((matched.score ?? 0) - (previous.score ?? 0))
|
||||
}
|
||||
|
||||
function roundScore(value: number) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user