优化考核调研评价人选择
This commit is contained in:
parent
de727a18e6
commit
83cf1ee2ca
@ -150,6 +150,16 @@ function matchesPickerSearch(keyword: string, values: unknown[]) {
|
||||
return values.some((value) => pickerSearchVariants(value).some((variant) => variant.includes(normalizedKeyword)))
|
||||
}
|
||||
|
||||
function mergeEmployeesById(...groups: Employee[][]) {
|
||||
const byId = new Map<string, Employee>()
|
||||
for (const group of groups) {
|
||||
for (const employee of group) {
|
||||
if (!byId.has(employee.id)) byId.set(employee.id, employee)
|
||||
}
|
||||
}
|
||||
return Array.from(byId.values())
|
||||
}
|
||||
|
||||
async function apiRequest<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const dingtalkToken = getStoredDingtalkPerformanceToken()
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
@ -962,8 +972,9 @@ function CreateSurveyForm({
|
||||
const [dueAt, setDueAt] = useState('')
|
||||
const [openNow, setOpenNow] = useState(true)
|
||||
const [targetEmployees, setTargetEmployees] = useState<Employee[]>([])
|
||||
const [evaluatorEmployees, setEvaluatorEmployees] = useState<Employee[]>([])
|
||||
const [evaluatorDepartments, setEvaluatorDepartments] = useState<string[]>([])
|
||||
const [manualEvaluatorEmployees, setManualEvaluatorEmployees] = useState<Employee[]>([])
|
||||
const [evaluatorDepartmentValues, setEvaluatorDepartmentValues] = useState<string[]>([])
|
||||
const [evaluatorDepartmentEmployees, setEvaluatorDepartmentEmployees] = useState<Record<string, Employee[]>>({})
|
||||
const [itemName, setItemName] = useState('')
|
||||
const [targetItems, setTargetItems] = useState<Array<{ name: string }>>([])
|
||||
const [createdSurvey, setCreatedSurvey] = useState<any | null>(null)
|
||||
@ -973,6 +984,22 @@ function CreateSurveyForm({
|
||||
const selectedTemplate = templateCode || templates[0]?.code || ''
|
||||
const selectedTemplateInfo = templates.find((template) => template.code === selectedTemplate)
|
||||
const surveyKind = selectedTemplateInfo?.surveyType || 'person_period'
|
||||
const evaluatorEmployees = useMemo(
|
||||
() => mergeEmployeesById(manualEvaluatorEmployees, ...Object.values(evaluatorDepartmentEmployees)),
|
||||
[manualEvaluatorEmployees, evaluatorDepartmentEmployees],
|
||||
)
|
||||
|
||||
const removeEvaluatorEmployee = (id: string) => {
|
||||
setManualEvaluatorEmployees((current) => current.filter((employee) => employee.id !== id))
|
||||
setEvaluatorDepartmentEmployees((current) => {
|
||||
const next: Record<string, Employee[]> = {}
|
||||
for (const [department, employees] of Object.entries(current)) {
|
||||
const filtered = employees.filter((employee) => employee.id !== id)
|
||||
if (filtered.length > 0) next[department] = filtered
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
setSaving(true)
|
||||
@ -1002,14 +1029,14 @@ function CreateSurveyForm({
|
||||
targetEmployeeIds: targetEmployees.map((employee) => employee.id),
|
||||
targetItems: targetItemsToSubmit,
|
||||
evaluatorEmployeeIds: evaluatorEmployees.map((employee) => employee.id),
|
||||
evaluatorDepartmentNames: evaluatorDepartments,
|
||||
}),
|
||||
})
|
||||
setTitle('')
|
||||
setDescription('')
|
||||
setTargetEmployees([])
|
||||
setEvaluatorEmployees([])
|
||||
setEvaluatorDepartments([])
|
||||
setManualEvaluatorEmployees([])
|
||||
setEvaluatorDepartmentValues([])
|
||||
setEvaluatorDepartmentEmployees({})
|
||||
setTargetItems([])
|
||||
setItemName('')
|
||||
setCreatedSurvey(created)
|
||||
@ -1045,16 +1072,18 @@ function CreateSurveyForm({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<TextInput
|
||||
label={selectedTemplateInfo?.itemNoteName || '备注'}
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder={selectedTemplateInfo?.itemNotePlaceholder || '填写必要背景'}
|
||||
/>
|
||||
<TextInput label={surveyKind === 'item_course' ? '发生时间' : '截止时间'} value={dueAt} onChange={setDueAt} type="datetime-local" />
|
||||
|
||||
{surveyKind === 'person_period' ? (
|
||||
<EmployeePicker title={selectedTemplateInfo?.ownerRoleName || '被考核人'} purpose="target" selected={targetEmployees} onChange={setTargetEmployees} />
|
||||
<>
|
||||
<EmployeePicker title={selectedTemplateInfo?.ownerRoleName || '被考核人'} purpose="target" selected={targetEmployees} onChange={setTargetEmployees} />
|
||||
<TextInput
|
||||
label={selectedTemplateInfo?.itemNoteName || '备注'}
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder={selectedTemplateInfo?.itemNotePlaceholder || '填写必要背景'}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<div className="mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">{selectedTemplateInfo?.itemName || '调研事项'}</div>
|
||||
@ -1082,14 +1111,40 @@ function CreateSurveyForm({
|
||||
items={targetItems.map((item) => ({ id: item.name, label: item.name }))}
|
||||
onRemove={(id) => setTargetItems((current) => current.filter((item) => item.name !== id))}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<TextInput
|
||||
label={selectedTemplateInfo?.itemNoteName || '备注'}
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder={selectedTemplateInfo?.itemNotePlaceholder || '填写必要背景'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EmployeePicker title="评价人" purpose="evaluator" selected={evaluatorEmployees} onChange={setEvaluatorEmployees} />
|
||||
<EmployeePicker
|
||||
title="评价人"
|
||||
purpose="evaluator"
|
||||
selected={evaluatorEmployees}
|
||||
onChange={setManualEvaluatorEmployees}
|
||||
onAddEmployee={(employee) => setManualEvaluatorEmployees((current) => mergeEmployeesById(current, [employee]))}
|
||||
onRemoveEmployee={removeEvaluatorEmployee}
|
||||
/>
|
||||
<DepartmentPicker
|
||||
departments={departments}
|
||||
selected={evaluatorDepartments}
|
||||
onChange={setEvaluatorDepartments}
|
||||
selected={evaluatorDepartmentValues}
|
||||
onChange={setEvaluatorDepartmentValues}
|
||||
onDepartmentEmployeesChange={(department, checked, employees) => {
|
||||
setEvaluatorDepartmentEmployees((current) => {
|
||||
const next = { ...current }
|
||||
if (checked) {
|
||||
next[department] = employees
|
||||
} else {
|
||||
delete next[department]
|
||||
}
|
||||
return next
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
@ -1119,11 +1174,15 @@ function EmployeePicker({
|
||||
purpose,
|
||||
selected,
|
||||
onChange,
|
||||
onAddEmployee,
|
||||
onRemoveEmployee,
|
||||
}: {
|
||||
title: string
|
||||
purpose?: 'target' | 'evaluator'
|
||||
selected: Employee[]
|
||||
onChange: (employees: Employee[]) => void
|
||||
onAddEmployee?: (employee: Employee) => void
|
||||
onRemoveEmployee?: (id: string) => void
|
||||
}) {
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [items, setItems] = useState<Employee[]>([])
|
||||
@ -1131,26 +1190,38 @@ function EmployeePicker({
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const pickerRef = useRef<HTMLDivElement | null>(null)
|
||||
const cacheRef = useRef<Map<string, Employee[]>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(async () => {
|
||||
if (!keyword.trim()) {
|
||||
const trimmedKeyword = keyword.trim()
|
||||
if (!trimmedKeyword) {
|
||||
setItems([])
|
||||
setError(null)
|
||||
setLoading(false)
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
const cacheKey = `${purpose || 'default'}:${trimmedKeyword}`
|
||||
const cached = cacheRef.current.get(cacheKey)
|
||||
if (cached) {
|
||||
setItems(cached)
|
||||
setError(null)
|
||||
setLoading(false)
|
||||
setOpen(true)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setOpen(true)
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
search: keyword,
|
||||
search: trimmedKeyword,
|
||||
limit: '50',
|
||||
...(purpose ? { purpose } : {}),
|
||||
})
|
||||
const data = await apiRequest<Employee[]>(`/employees?${params.toString()}`)
|
||||
cacheRef.current.set(cacheKey, data)
|
||||
setItems(data)
|
||||
} catch (err) {
|
||||
setItems([])
|
||||
@ -1177,9 +1248,15 @@ function EmployeePicker({
|
||||
const selectedIds = new Set(selected.map((employee) => employee.id))
|
||||
const selectEmployee = (employee: Employee) => {
|
||||
if (selectedIds.has(employee.id)) return
|
||||
onChange([...selected, employee])
|
||||
if (onAddEmployee) {
|
||||
onAddEmployee(employee)
|
||||
} else {
|
||||
onChange([...selected, employee])
|
||||
}
|
||||
setError(null)
|
||||
setOpen(true)
|
||||
setKeyword('')
|
||||
setItems([])
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -1234,7 +1311,13 @@ function EmployeePicker({
|
||||
)}
|
||||
<SelectedChips
|
||||
items={selected.map((employee) => ({ id: employee.id, label: employee.displayName }))}
|
||||
onRemove={(id) => onChange(selected.filter((employee) => employee.id !== id))}
|
||||
onRemove={(id) => {
|
||||
if (onRemoveEmployee) {
|
||||
onRemoveEmployee(id)
|
||||
} else {
|
||||
onChange(selected.filter((employee) => employee.id !== id))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@ -1244,13 +1327,18 @@ function DepartmentPicker({
|
||||
departments,
|
||||
selected,
|
||||
onChange,
|
||||
onDepartmentEmployeesChange,
|
||||
}: {
|
||||
departments: DepartmentOption[]
|
||||
selected: string[]
|
||||
onChange: (items: string[]) => void
|
||||
onDepartmentEmployeesChange?: (value: string, checked: boolean, employees: Employee[]) => void
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [loadingValue, setLoadingValue] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const employeeCacheRef = useRef<Map<string, Employee[]>>(new Map())
|
||||
|
||||
const { roots, childrenByParent, optionByValue, visibleIds } = useMemo(() => {
|
||||
const normalizedKeyword = keyword.trim().toLowerCase()
|
||||
@ -1322,12 +1410,43 @@ function DepartmentPicker({
|
||||
})
|
||||
}
|
||||
|
||||
const toggleValue = (value: string, checked: boolean) => {
|
||||
onChange(
|
||||
checked
|
||||
? Array.from(new Set([...selected, value]))
|
||||
: selected.filter((item) => item !== value),
|
||||
)
|
||||
const getDepartmentEmployees = async (value: string) => {
|
||||
const cached = employeeCacheRef.current.get(value)
|
||||
if (cached) return cached
|
||||
|
||||
const params = new URLSearchParams({
|
||||
department: value,
|
||||
limit: '1000',
|
||||
purpose: 'evaluator',
|
||||
})
|
||||
const employees = await apiRequest<Employee[]>(`/employees?${params.toString()}`)
|
||||
employeeCacheRef.current.set(value, employees)
|
||||
return employees
|
||||
}
|
||||
|
||||
const toggleValue = async (value: string, checked: boolean) => {
|
||||
if (!checked) {
|
||||
onChange(selected.filter((item) => item !== value))
|
||||
onDepartmentEmployeesChange?.(value, false, [])
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
|
||||
setLoadingValue(value)
|
||||
setError(null)
|
||||
try {
|
||||
const employees = await getDepartmentEmployees(value)
|
||||
if (employees.length === 0) {
|
||||
setError('该部门暂无可选人员')
|
||||
return
|
||||
}
|
||||
onChange(Array.from(new Set([...selected, value])))
|
||||
onDepartmentEmployeesChange?.(value, true, employees)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '部门人员加载失败')
|
||||
} finally {
|
||||
setLoadingValue(null)
|
||||
}
|
||||
}
|
||||
|
||||
const renderNode = (department: DepartmentOption, depth = 0): ReactNode => {
|
||||
@ -1363,10 +1482,16 @@ function DepartmentPicker({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(department.value)}
|
||||
onChange={(event) => toggleValue(department.value!, event.target.checked)}
|
||||
disabled={loadingValue === department.value}
|
||||
onChange={(event) => {
|
||||
void toggleValue(department.value!, event.target.checked)
|
||||
}}
|
||||
aria-label={`选择${department.name}`}
|
||||
/>
|
||||
)}
|
||||
{department.value && loadingValue === department.value && (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">加载中</span>
|
||||
)}
|
||||
</div>
|
||||
{hasChildren && isExpanded && children.map((child) => renderNode(child, depth + 1))}
|
||||
</div>
|
||||
@ -1382,7 +1507,11 @@ function DepartmentPicker({
|
||||
{selected.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange([])}
|
||||
onClick={() => {
|
||||
selected.forEach((value) => onDepartmentEmployeesChange?.(value, false, []))
|
||||
onChange([])
|
||||
setError(null)
|
||||
}}
|
||||
className="text-xs text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
||||
>
|
||||
清空
|
||||
@ -1398,13 +1527,16 @@ function DepartmentPicker({
|
||||
<div className="max-h-64 overflow-y-auto rounded-[10px] border border-gray-200 p-1.5 dark:border-gray-700">
|
||||
{roots.map((department) => renderNode(department))}
|
||||
</div>
|
||||
{error && <div className="mt-2 text-xs text-red-600 dark:text-red-300">{error}</div>}
|
||||
{selectedLabels.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{selectedLabels.map((department) => (
|
||||
<button
|
||||
key={department.value || department.id}
|
||||
type="button"
|
||||
onClick={() => department.value && toggleValue(department.value, false)}
|
||||
onClick={() => {
|
||||
if (department.value) void toggleValue(department.value, false)
|
||||
}}
|
||||
className="rounded-md bg-gray-100 px-2.5 py-1 text-sm text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200"
|
||||
>
|
||||
{department.fullPath || department.departmentPath || (
|
||||
|
||||
@ -469,7 +469,7 @@ export async function searchPerformanceSurveyEmployees(session: Session | null,
|
||||
const ctx = await getPerformanceSurveyContext(session)
|
||||
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 limit = Math.max(1, Math.min(Number(params.get('limit') || 50), 1000))
|
||||
const isEvaluatorSearch = clean(params.get('purpose')) === 'evaluator'
|
||||
|
||||
const buildWhere = async (includeKeyword: boolean) => {
|
||||
@ -512,7 +512,8 @@ export async function searchPerformanceSurveyEmployees(session: Session | null,
|
||||
|
||||
let employees = await queryEmployees()
|
||||
|
||||
if (search && employees.length < limit) {
|
||||
const needsPinyinSearch = search && (employees.length === 0 || /[A-Za-z]/.test(search))
|
||||
if (needsPinyinSearch && employees.length < limit) {
|
||||
const candidates = await prisma.performanceSurveyEmployeeProfile.findMany({
|
||||
where: await buildWhere(false),
|
||||
orderBy: [{ departmentName: 'asc' }, { displayName: 'asc' }],
|
||||
@ -538,92 +539,6 @@ export async function searchPerformanceSurveyEmployees(session: Session | null,
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user