688 lines
27 KiB
TypeScript
688 lines
27 KiB
TypeScript
'use client'
|
||
|
||
import Select from '@/components/ui/Select'
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
|
||
export type PerformanceSurveyAdminRequest = <T>(path: string, options?: RequestInit) => Promise<T>
|
||
|
||
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<AssignmentPayload>(`/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<CreatePositionAssignmentsInput, 'employeeIds'>,
|
||
) {
|
||
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 (
|
||
<span className="mt-1 flex flex-wrap gap-1">
|
||
{labels.map((label) => (
|
||
<span key={label} className="inline-flex rounded bg-[#f4f3f3] px-1.5 py-0.5 text-[11px] font-semibold text-[#5f5e5e]">
|
||
{label}
|
||
</span>
|
||
))}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function DepartmentSummary({ row, compact = false }: { row: AssignmentRow; compact?: boolean }) {
|
||
const { primaryLabel, secondaryLabels } = readDepartmentSummary(row)
|
||
const secondarySummary = summarizeDepartments(secondaryLabels)
|
||
const allSecondaryDepartments = secondaryLabels.join('、')
|
||
|
||
return (
|
||
<span className="block min-w-0">
|
||
<span className="flex min-w-0 items-start gap-1.5">
|
||
<span className="mt-0.5 shrink-0 rounded bg-[#eef5ff] px-1.5 py-0.5 text-[10px] font-bold text-[#0050cb]">主部门</span>
|
||
<span className={`${compact ? 'line-clamp-2' : 'break-words'} min-w-0 text-[12px] leading-5 text-[#3f4247]`} title={primaryLabel}>
|
||
{primaryLabel}
|
||
</span>
|
||
</span>
|
||
{secondaryLabels.length > 0 && (
|
||
<span className="mt-1 flex min-w-0 items-start gap-1.5">
|
||
<span className="mt-0.5 shrink-0 rounded bg-[#f4f3f3] px-1.5 py-0.5 text-[10px] font-bold text-[#5f5e5e]">兼任</span>
|
||
<span className="min-w-0 text-[12px] leading-5 text-[#5f5e5e]" title={allSecondaryDepartments}>
|
||
{secondarySummary}
|
||
</span>
|
||
</span>
|
||
)}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function PositionSummary({ row }: { row: AssignmentRow }) {
|
||
const level = readPositionLevel(row)
|
||
return (
|
||
<span className="block min-w-0">
|
||
<span className="break-words text-[12px] leading-5 text-[#3f4247]">{row.positionTitle || '职位未填写'}</span>
|
||
{(level || row.isLeadership) && (
|
||
<span className="mt-1 flex flex-wrap gap-1">
|
||
{level && (
|
||
<span className="inline-flex rounded bg-[#eef5ff] px-1.5 py-0.5 text-[11px] font-bold text-[#0050cb]">
|
||
{level}
|
||
</span>
|
||
)}
|
||
{row.isLeadership && (
|
||
<span className="inline-flex rounded bg-[#fff4df] px-1.5 py-0.5 text-[11px] font-semibold text-[#8a5700]">
|
||
管理岗
|
||
</span>
|
||
)}
|
||
</span>
|
||
)}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
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<AssignmentRow[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [loadError, setLoadError] = useState<{ message: string; technicalDetail: string | null } | null>(null)
|
||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||
const selectedRowsRef = useRef<Map<string, AssignmentRow>>(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 (
|
||
<section aria-labelledby="performance-survey-people-title">
|
||
<div className="border-b border-[#e2e2e2] pb-4">
|
||
<h2 id="performance-survey-people-title" className="text-[18px] font-bold text-[#1a1c1c]">人员分配</h2>
|
||
<p className="mt-1 max-w-3xl text-[13px] leading-5 text-[#5f5e5e]">
|
||
按 {month} 维护人员所属考核岗位;人员异动后可重新分配,或明确标记为不参与考核。
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid gap-3 border-b border-[#e2e2e2] py-4 md:grid-cols-[minmax(220px,1fr)_180px_auto]">
|
||
<label className="grid gap-1 text-[12px] font-semibold text-[#5f5e5e]">
|
||
搜索人员
|
||
<input
|
||
value={keyword}
|
||
onChange={(event) => setKeyword(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter') void load()
|
||
}}
|
||
placeholder="姓名、部门、职位或职级"
|
||
className="h-11 rounded border border-[#c2c6d8] px-3 text-[13px] text-[#1a1c1c] outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dbe6ff]"
|
||
/>
|
||
</label>
|
||
<label className="grid gap-1 text-[12px] font-semibold text-[#5f5e5e]">
|
||
分配状态
|
||
<Select
|
||
value={statusFilter}
|
||
onChange={(event) => setStatusFilter(event.target.value as typeof statusFilter)}
|
||
className="h-11 rounded border border-[#c2c6d8] bg-white px-3 text-[13px] text-[#1a1c1c] outline-none focus:border-[#0050cb]"
|
||
>
|
||
<option value="all">全部人员</option>
|
||
<option value="assigned">已分配</option>
|
||
<option value="unassigned">待分配</option>
|
||
<option value="excluded">不参与考核</option>
|
||
</Select>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => void load()}
|
||
className="min-h-11 self-end rounded border border-[#1a1c1c] px-4 text-[13px] font-bold text-[#1a1c1c] hover:bg-[#f4f3f3] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb]"
|
||
>
|
||
查询
|
||
</button>
|
||
</div>
|
||
|
||
{loadError && (
|
||
<div role="alert" className="my-4 flex flex-wrap items-center justify-between gap-3 border border-[#f0b8b8] bg-[#fff8f7] px-4 py-3 text-[13px] text-[#93000a]">
|
||
<div>
|
||
<p className="font-semibold">{loadError.message}</p>
|
||
{loadError.technicalDetail && (
|
||
<details className="mt-1 text-[12px] text-[#5f5e5e]">
|
||
<summary className="cursor-pointer">查看技术信息</summary>
|
||
<p className="mt-1 break-all">{loadError.technicalDetail}</p>
|
||
</details>
|
||
)}
|
||
</div>
|
||
<button type="button" onClick={() => void load()} className="min-h-11 px-3 font-bold underline">重新加载</button>
|
||
</div>
|
||
)}
|
||
|
||
<div role="status" aria-live="polite" className="sr-only">
|
||
{loading ? '正在加载人员列表' : `人员列表已更新,共 ${total} 人`}
|
||
</div>
|
||
|
||
<div className="mt-4 flex min-h-11 items-center justify-between gap-3 border border-[#c2c6d8] bg-[#f8f8f8] px-3 py-2 text-[12px] font-semibold text-[#5f5e5e]">
|
||
<span>本页 {rows.length} 人{selectedIds.size > 0 ? ` · 已跨页选择 ${selectedIds.size} 人` : ''}</span>
|
||
<button
|
||
type="button"
|
||
disabled={rows.length === 0 || loading}
|
||
onClick={toggleAll}
|
||
className="min-h-11 rounded px-3 font-bold text-[#0050cb] hover:bg-[#eef5ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] disabled:opacity-40"
|
||
>
|
||
{allVisibleSelected ? '取消选择本页' : '选择本页全部'}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="hidden overflow-x-auto border-x border-b border-[#c2c6d8] md:block" data-testid="people-desktop-table">
|
||
<table className="w-full min-w-[760px] border-collapse text-left">
|
||
<thead>
|
||
<tr className="border-b border-[#c2c6d8] bg-[#f4f3f3] text-[12px] font-bold text-[#5f5e5e]">
|
||
<th scope="col" className="w-12 px-3 py-3">
|
||
<label className="inline-flex min-h-11 min-w-11 cursor-pointer items-center justify-center" title="选择当前列表全部人员">
|
||
<input
|
||
type="checkbox"
|
||
className="h-5 w-5"
|
||
checked={allVisibleSelected}
|
||
onChange={toggleAll}
|
||
aria-label="选择当前列表全部人员"
|
||
/>
|
||
</label>
|
||
</th>
|
||
<th scope="col" className="px-3 py-3">人员</th>
|
||
<th scope="col" className="min-w-[240px] px-3 py-3">部门归属</th>
|
||
<th scope="col" className="min-w-[140px] px-3 py-3">职位 / 职级</th>
|
||
<th scope="col" className="w-[180px] px-3 py-3">当前分配</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-[#e2e2e2]">
|
||
{loading && (
|
||
<tr>
|
||
<td colSpan={5} className="px-4 py-10 text-center text-[13px] font-semibold text-[#5f5e5e]">人员加载中...</td>
|
||
</tr>
|
||
)}
|
||
{!loading && rows.map((row) => (
|
||
<tr key={row.id} className={selectedIds.has(row.id) ? 'bg-[#f4f7ff]' : 'bg-white hover:bg-[#fcfcfc]'}>
|
||
<td className="px-3 py-1">
|
||
<label className="inline-flex min-h-11 min-w-11 cursor-pointer items-center justify-center" title={`选择 ${row.displayName}`}>
|
||
<input
|
||
type="checkbox"
|
||
className="h-5 w-5"
|
||
checked={selectedIds.has(row.id)}
|
||
onChange={() => toggleRow(row)}
|
||
aria-label={`在表格中选择 ${row.displayName}`}
|
||
/>
|
||
</label>
|
||
</td>
|
||
<td className="px-3 py-3 text-[13px] font-bold text-[#1a1c1c]">
|
||
<span className="block">{row.displayName}</span>
|
||
<DingtalkIdentityBadges row={row} />
|
||
</td>
|
||
<td className="px-3 py-3"><DepartmentSummary row={row} /></td>
|
||
<td className="px-3 py-3"><PositionSummary row={row} /></td>
|
||
<td className="px-3 py-3">
|
||
<span className={`inline-flex rounded px-2 py-1 text-[12px] font-semibold ${
|
||
row.participation === 'excluded'
|
||
? 'bg-[#f4f3f3] text-[#5f5e5e]'
|
||
: row.positionGroupName
|
||
? 'bg-[#eef5ff] text-[#0050cb]'
|
||
: 'bg-[#fff4df] text-[#8a5700]'
|
||
}`}>
|
||
{participationLabel(row)}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{!loading && rows.length === 0 && !loadError && (
|
||
<tr>
|
||
<td colSpan={5} className="px-4 py-10 text-center text-[13px] font-semibold text-[#5f5e5e]">
|
||
当前条件下没有人员
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div className="divide-y divide-[#e2e2e2] border-x border-b border-[#c2c6d8] bg-white md:hidden" data-testid="people-mobile-cards">
|
||
{loading && (
|
||
<div className="px-4 py-10 text-center text-[13px] font-semibold text-[#5f5e5e]">
|
||
人员加载中...
|
||
</div>
|
||
)}
|
||
{!loading && rows.map((row) => (
|
||
<label
|
||
key={row.id}
|
||
className={`flex min-h-20 cursor-pointer gap-3 p-3 ${
|
||
selectedIds.has(row.id) ? 'bg-[#f4f7ff]' : 'bg-white active:bg-[#f4f3f3]'
|
||
}`}
|
||
>
|
||
<span className="inline-flex min-h-11 min-w-11 items-start justify-center pt-1">
|
||
<input
|
||
type="checkbox"
|
||
className="h-5 w-5"
|
||
checked={selectedIds.has(row.id)}
|
||
onChange={() => toggleRow(row)}
|
||
aria-label={`选择 ${row.displayName}`}
|
||
/>
|
||
</span>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="flex flex-wrap items-start justify-between gap-2">
|
||
<span className="min-w-0 text-[14px] font-bold text-[#1a1c1c]">
|
||
<span className="block">{row.displayName}</span>
|
||
<DingtalkIdentityBadges row={row} />
|
||
</span>
|
||
<span className={`inline-flex rounded px-2 py-1 text-[12px] font-semibold ${
|
||
row.participation === 'excluded'
|
||
? 'bg-[#f4f3f3] text-[#5f5e5e]'
|
||
: row.positionGroupName
|
||
? 'bg-[#eef5ff] text-[#0050cb]'
|
||
: 'bg-[#fff4df] text-[#8a5700]'
|
||
}`}>
|
||
{participationLabel(row)}
|
||
</span>
|
||
</span>
|
||
<span className="mt-2 grid gap-2">
|
||
<DepartmentSummary row={row} compact />
|
||
<span className="flex min-w-0 items-start gap-1.5">
|
||
<span className="mt-0.5 shrink-0 rounded bg-[#f4f3f3] px-1.5 py-0.5 text-[10px] font-bold text-[#5f5e5e]">职位</span>
|
||
<PositionSummary row={row} />
|
||
</span>
|
||
</span>
|
||
</span>
|
||
</label>
|
||
))}
|
||
{!loading && rows.length === 0 && !loadError && (
|
||
<div className="px-4 py-10 text-center text-[13px] font-semibold text-[#5f5e5e]">
|
||
当前条件下没有人员
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center justify-between gap-3 border-x border-b border-[#c2c6d8] px-3 py-3 text-[12px] font-semibold text-[#5f5e5e]">
|
||
<span>
|
||
共 {total} 人
|
||
{total > 0 ? ` · 当前显示 ${(page - 1) * pageSize + 1}–${Math.min(page * pageSize, total)}` : ''}
|
||
</span>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
disabled={page <= 1 || loading}
|
||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||
className="min-h-11 rounded border border-[#c2c6d8] px-3 font-bold text-[#1a1c1c] disabled:opacity-40"
|
||
>
|
||
上一页
|
||
</button>
|
||
<span>第 {page} / {Math.max(1, Math.ceil(total / pageSize))} 页</span>
|
||
<button
|
||
type="button"
|
||
disabled={page * pageSize >= total || loading}
|
||
onClick={() => setPage((current) => current + 1)}
|
||
className="min-h-11 rounded border border-[#c2c6d8] px-3 font-bold text-[#1a1c1c] disabled:opacity-40"
|
||
>
|
||
下一页
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{selectedIds.size > 0 && (
|
||
<>
|
||
<div aria-hidden="true" className="h-[220px] sm:h-24" />
|
||
<div
|
||
role="region"
|
||
aria-label="批量分配所选人员"
|
||
className="fixed bottom-[72px] left-3 right-3 z-40 border border-[#c2c6d8] bg-white/95 p-3 shadow-[0_-6px_20px_rgba(0,0,0,0.14)] backdrop-blur sm:bottom-4 sm:left-1/2 sm:right-auto sm:w-[min(960px,calc(100vw-2rem))] sm:-translate-x-1/2 sm:flex sm:items-end sm:justify-between sm:gap-3"
|
||
>
|
||
<div className="mb-2 flex min-h-11 items-center justify-between gap-3 text-[12px] font-semibold text-[#5f5e5e] sm:mb-0 sm:min-w-[128px]">
|
||
<span>已选择 {selectedIds.size} 人</span>
|
||
<button
|
||
type="button"
|
||
onClick={clearSelection}
|
||
className="min-h-11 rounded px-3 font-bold text-[#0050cb] hover:bg-[#eef5ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb]"
|
||
>
|
||
清空选择
|
||
</button>
|
||
</div>
|
||
<div className="grid gap-2 sm:flex sm:min-w-0 sm:flex-1">
|
||
<div className="grid min-w-0 gap-2 sm:flex sm:flex-1">
|
||
<Select
|
||
aria-label="批量分配操作"
|
||
value={assignmentValue}
|
||
onChange={(event) => setAssignmentValue(event.target.value)}
|
||
className="h-11 min-w-0 rounded border border-[#c2c6d8] bg-white px-3 text-[13px] font-semibold text-[#1a1c1c] outline-none focus:border-[#0050cb] sm:w-[220px]"
|
||
>
|
||
<option value="">选择分配结果</option>
|
||
{groups.filter((group) => group.status !== 'disabled').map((group) => (
|
||
<option key={group.id} value={group.id}>{group.name}</option>
|
||
))}
|
||
<option value="__excluded__">不参与考核</option>
|
||
<option value="__automatic__">恢复自动匹配</option>
|
||
</Select>
|
||
<input
|
||
aria-label="本次调整原因"
|
||
value={reason}
|
||
onChange={(event) => setReason(event.target.value)}
|
||
placeholder="本次调整原因(选填)"
|
||
maxLength={500}
|
||
className="h-11 min-w-0 rounded border border-[#c2c6d8] px-3 text-[13px] text-[#1a1c1c] outline-none focus:border-[#0050cb] sm:flex-1"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
disabled={saving || selectedIds.size === 0}
|
||
onClick={() => void applyAssignment()}
|
||
className="min-h-11 rounded bg-[#0050cb] px-4 text-[13px] font-bold text-white hover:bg-[#003fa4] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:shrink-0"
|
||
>
|
||
{saving ? '处理中...' : '应用分配'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|