gongxiangp2/app/performance-survey/PerformanceSurveyAdminPeople.tsx

688 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
)
}