'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' export type PerformanceSurveyAdminRequest = (path: string, options?: RequestInit) => Promise export type PerformanceSurveyAdminPositionOption = { id: string name: string status: 'ready' | 'pending' | 'disabled' memberCount: number } type AssignmentRow = { id: string employeeId?: string displayName: string departmentName?: string | null departmentPath?: string | null departmentMemberships?: Array<{ id: string sourceDeptId?: string | null departmentName?: string | null departmentPath?: string | null isPrimary?: boolean syncedAt?: string | null }> positionTitle?: string | null positionLevel?: string | null levelType?: string | null isLeadership?: boolean isDingtalkAdmin?: boolean isDingtalkBoss?: boolean isDingtalkSenior?: boolean positionGroupId?: string | null positionGroupName?: string | null manualAssignmentId?: string | null assignmentType?: 'position' | 'excluded' participation?: 'assigned' | 'unassigned' | 'excluded' } type AssignmentPayload = | AssignmentRow[] | { items?: AssignmentRow[] employees?: AssignmentRow[] total?: number page?: number pageSize?: number } type CreatePositionAssignmentsInput = { employeeIds: string[] positionGroupId: string | null assignmentType: 'position' | 'excluded' effectiveFromMonth: string effectiveToMonth: string | null reason: string } const positionAssignmentApi = { list( request: PerformanceSurveyAdminRequest, input: { month: string; search: string; status: string; page: number; pageSize: number }, ) { const params = new URLSearchParams({ month: input.month, page: String(input.page), pageSize: String(input.pageSize), }) if (input.search) params.set('search', input.search) if (input.status !== 'all') params.set('status', input.status) return request(`/admin/position-assignments?${params.toString()}`) }, create(request: PerformanceSurveyAdminRequest, input: CreatePositionAssignmentsInput) { return request('/admin/position-assignments', { method: 'POST', body: JSON.stringify(input), }) }, update( request: PerformanceSurveyAdminRequest, assignmentId: string, input: Omit, ) { return request(`/admin/position-assignments/${assignmentId}`, { method: 'PATCH', body: JSON.stringify(input), }) }, archive(request: PerformanceSurveyAdminRequest, assignmentId: string, reason: string) { return request(`/admin/position-assignments/${assignmentId}`, { method: 'DELETE', body: JSON.stringify({ reason }), }) }, } function normalizeAssignmentRows(payload: AssignmentPayload | null): AssignmentRow[] { if (Array.isArray(payload)) return payload if (Array.isArray(payload?.items)) return payload.items if (Array.isArray(payload?.employees)) return payload.employees return [] } function readAssignmentTotal(payload: AssignmentPayload | null, rows: AssignmentRow[]) { if (Array.isArray(payload)) return payload.length return Number.isFinite(Number(payload?.total)) ? Number(payload?.total) : rows.length } function participationLabel(row: AssignmentRow) { if (row.assignmentType === 'excluded' || row.participation === 'excluded') return '不参与考核' if (row.positionGroupName) return row.positionGroupName return '待分配' } function readPositionLevel(row: AssignmentRow) { const level = row.positionLevel?.trim() const type = row.levelType?.trim().toUpperCase() if (!level) return type || null if (/^[MP]\d+/i.test(level)) return level.toUpperCase() if (type && /^\d/.test(level)) return `${type}${level}` return level } function readDepartmentSummary(row: AssignmentRow) { const memberships = (row.departmentMemberships || []) .filter((membership) => membership.departmentName?.trim() || membership.departmentPath?.trim()) const legacyPrimary = row.departmentPath?.trim() || row.departmentName?.trim() || null const explicitPrimary = memberships.find((membership) => membership.isPrimary) const matchingPrimary = memberships.find((membership) => ( membership.departmentPath?.trim() === row.departmentPath?.trim() || membership.departmentName?.trim() === row.departmentName?.trim() )) const primary = explicitPrimary || matchingPrimary || (legacyPrimary ? null : memberships[0]) const primaryLabel = primary?.departmentPath?.trim() || primary?.departmentName?.trim() || legacyPrimary || '主部门未填写' const primaryId = primary?.id const secondaryLabels = Array.from(new Set( memberships .filter((membership) => membership.id !== primaryId && !membership.isPrimary) .map((membership) => membership.departmentName?.trim() || membership.departmentPath?.trim() || '') .filter((label) => label && label !== primaryLabel && label !== row.departmentName?.trim()), )) return { primaryLabel, secondaryLabels } } function summarizeDepartments(departments: string[]) { if (departments.length <= 2) return departments.join('、') return `${departments.slice(0, 2).join('、')} 等 ${departments.length} 个部门` } function DingtalkIdentityBadges({ row }: { row: AssignmentRow }) { const labels: string[] = [] if (row.isDingtalkAdmin) labels.push('钉钉管理员') if (row.isDingtalkBoss) labels.push('钉钉企业负责人') if (row.isDingtalkSenior) labels.push('钉钉高管') if (labels.length === 0) return null return ( {labels.map((label) => ( {label} ))} ) } function DepartmentSummary({ row, compact = false }: { row: AssignmentRow; compact?: boolean }) { const { primaryLabel, secondaryLabels } = readDepartmentSummary(row) const secondarySummary = summarizeDepartments(secondaryLabels) const allSecondaryDepartments = secondaryLabels.join('、') return ( 主部门 {primaryLabel} {secondaryLabels.length > 0 && ( 兼任 {secondarySummary} )} ) } function PositionSummary({ row }: { row: AssignmentRow }) { const level = readPositionLevel(row) return ( {row.positionTitle || '职位未填写'} {(level || row.isLeadership) && ( {level && ( {level} )} {row.isLeadership && ( 管理岗 )} )} ) } function readTechnicalError(error: unknown) { if (!(error instanceof Error)) return null const detail = error.message.trim() return detail && detail !== 'Failed to fetch' ? detail : null } export function PerformanceSurveyAdminPeople({ month, groups, request, onError, onSuccess, }: { month: string groups: PerformanceSurveyAdminPositionOption[] request: PerformanceSurveyAdminRequest onError: (message: string | null) => void onSuccess: (message: string) => void }) { const [keyword, setKeyword] = useState('') const [statusFilter, setStatusFilter] = useState<'all' | 'assigned' | 'unassigned' | 'excluded'>('all') const [rows, setRows] = useState([]) const [loading, setLoading] = useState(true) const [loadError, setLoadError] = useState<{ message: string; technicalDetail: string | null } | null>(null) const [selectedIds, setSelectedIds] = useState>(new Set()) const selectedRowsRef = useRef>(new Map()) const [assignmentValue, setAssignmentValue] = useState('') const [reason, setReason] = useState('') const [saving, setSaving] = useState(false) const [page, setPage] = useState(1) const [total, setTotal] = useState(0) const requestSequence = useRef(0) const pageSize = 50 const load = useCallback(async () => { const sequence = requestSequence.current + 1 requestSequence.current = sequence setLoading(true) setLoadError(null) try { const payload = await positionAssignmentApi.list(request, { month, search: keyword.trim(), status: statusFilter, page, pageSize, }) if (requestSequence.current !== sequence) return const nextRows = normalizeAssignmentRows(payload) setRows(nextRows) setTotal(readAssignmentTotal(payload, nextRows)) } catch (error) { if (requestSequence.current !== sequence) return setRows([]) setLoadError({ message: '暂时无法加载人员,请检查网络后重试。', technicalDetail: readTechnicalError(error), }) } finally { if (requestSequence.current === sequence) setLoading(false) } }, [keyword, month, page, request, statusFilter]) useEffect(() => { setPage(1) setSelectedIds(new Set()) selectedRowsRef.current.clear() }, [keyword, month, statusFilter]) useEffect(() => { void load() return () => { requestSequence.current += 1 } }, [load]) const visibleIds = useMemo(() => rows.map((row) => row.id), [rows]) const allVisibleSelected = visibleIds.length > 0 && visibleIds.every((id) => selectedIds.has(id)) const toggleAll = () => { setSelectedIds((current) => { const next = new Set(current) if (allVisibleSelected) { visibleIds.forEach((id) => { next.delete(id) selectedRowsRef.current.delete(id) }) return next } rows.forEach((row) => { next.add(row.id) selectedRowsRef.current.set(row.id, row) }) return next }) } const toggleRow = (row: AssignmentRow) => { setSelectedIds((current) => { const next = new Set(current) if (next.has(row.id)) { next.delete(row.id) selectedRowsRef.current.delete(row.id) } else { next.add(row.id) selectedRowsRef.current.set(row.id, row) } return next }) } const clearSelection = () => { selectedRowsRef.current.clear() setSelectedIds(new Set()) } const applyAssignment = async () => { if (selectedIds.size === 0) { onError('请先选择人员') return } if (!assignmentValue) { onError('请选择要分配的岗位或“不参与考核”') return } setSaving(true) onError(null) try { const selectedRows = Array.from(selectedRowsRef.current.values()) const employeeIds = selectedRows.map((row) => row.employeeId || row.id) if (assignmentValue === '__automatic__') { const manualRows = selectedRows.filter((row) => row.manualAssignmentId) if (manualRows.length === 0) { onError('所选人员当前没有需要移除的手动分配') return } await Promise.all(manualRows.map((row) => ( positionAssignmentApi.archive(request, row.manualAssignmentId!, reason.trim()) ))) onSuccess(`已将 ${manualRows.length} 人恢复为按岗位规则自动匹配`) setReason('') clearSelection() await load() return } const assignmentInput = { effectiveFromMonth: month, effectiveToMonth: null, assignmentType: assignmentValue === '__excluded__' ? 'excluded' : 'position', positionGroupId: assignmentValue === '__excluded__' ? null : assignmentValue, reason: reason.trim(), } as const const rowsWithManualAssignment = selectedRows.filter((row) => row.manualAssignmentId) const newEmployeeIds = selectedRows .filter((row) => !row.manualAssignmentId) .map((row) => row.employeeId || row.id) await Promise.all([ ...(newEmployeeIds.length > 0 ? [positionAssignmentApi.create(request, { ...assignmentInput, employeeIds: newEmployeeIds })] : []), ...rowsWithManualAssignment.map((row) => ( positionAssignmentApi.update(request, row.manualAssignmentId!, assignmentInput) )), ]) onSuccess( assignmentValue === '__excluded__' ? `已将 ${employeeIds.length} 人设为不参与考核` : `已为 ${employeeIds.length} 人分配岗位`, ) setReason('') clearSelection() await load() } catch (error) { const technicalDetail = readTechnicalError(error) onError( technicalDetail ? `人员分配未能保存,请稍后重试。技术信息:${technicalDetail}` : '人员分配未能保存,请检查网络后重试。', ) } finally { setSaving(false) } } return (

人员分配

按 {month} 维护人员所属考核岗位;人员异动后可重新分配,或明确标记为不参与考核。

{loadError && (

{loadError.message}

{loadError.technicalDetail && (
查看技术信息

{loadError.technicalDetail}

)}
)}
{loading ? '正在加载人员列表' : `人员列表已更新,共 ${total} 人`}
本页 {rows.length} 人{selectedIds.size > 0 ? ` · 已跨页选择 ${selectedIds.size} 人` : ''}
{loading && ( )} {!loading && rows.map((row) => ( ))} {!loading && rows.length === 0 && !loadError && ( )}
人员 部门归属 职位 / 职级 当前分配
人员加载中...
{row.displayName} {participationLabel(row)}
当前条件下没有人员
{loading && (
人员加载中...
)} {!loading && rows.map((row) => ( ))} {!loading && rows.length === 0 && !loadError && (
当前条件下没有人员
)}
共 {total} 人 {total > 0 ? ` · 当前显示 ${(page - 1) * pageSize + 1}–${Math.min(page * pageSize, total)}` : ''}
第 {page} / {Math.max(1, Math.ceil(total / pageSize))} 页
{selectedIds.size > 0 && ( <>
) }