gongxiangp2/app/performance-survey/PerformanceSurveyAdminConfig.tsx
2026-07-27 18:32:34 +08:00

1901 lines
83 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
useConfirmActionDialog,
type ConfirmActionOptions,
type ConfirmActionResult,
} from '@/components/ConfirmActionDialog'
import {
PerformanceSurveyAdminPeople,
type PerformanceSurveyAdminRequest,
} from './PerformanceSurveyAdminPeople'
type RequestFn = PerformanceSurveyAdminRequest
type ConfirmActionFn = (options: ConfirmActionOptions) => Promise<ConfirmActionResult>
export type PerformanceSurveyAdminMode = 'overview' | 'positions' | 'people' | 'templates' | 'plans'
type ReviewerSource = 'self' | 'direct_manager' | 'fixed_employee' | 'manual_selection'
type ReviewerDraft = {
source: ReviewerSource
label: string
employeeId?: string
employeeName?: string
}
type StageDraft = {
key: string
label: string
order: number
reviewers: ReviewerDraft[]
}
type TemplateDefinition = {
code: string
name: string
shortName?: string
description: string
surveyType: 'person_period' | 'visual_case' | 'item_course'
weight: number
maxTotal?: number
questionMaxScore?: number
requiredWorkItemCount?: number
workItemImageRequired?: boolean
questions: Array<{ id: string; title: string; description?: string; maxScore?: number }>
scoreBands: unknown[]
workflow: {
stages: StageDraft[]
scoring: {
mode: 'final_reviewer' | 'average_reviewers'
stageKey?: string
reviewerSource?: ReviewerSource
stageKeys?: string[]
}
}
}
type TemplateVersion = {
id: string
version: number
status: 'draft' | 'published' | 'archived'
effectiveMonth?: string | null
publishedAt?: string | null
definition: TemplateDefinition
}
type TemplateRecord = {
id: string
code: string
name: string
description: string
surveyType: string
isActive: boolean
versions: TemplateVersion[]
}
type PositionGroupRecord = {
id: string
code: string
name: string
sortOrder: number
isActive: boolean
status: 'ready' | 'pending' | 'disabled'
memberCount: number
rules: Array<{
id: string
departmentPathPrefix: string
positionTitle: string
isActive: boolean
sortOrder: number
}>
effectivePlan: null | {
id: string
version: number
effectiveMonth: string
templateVersionIds: string[]
}
draftPlan: null | {
id: string
version: number
templateVersionIds: string[]
}
}
type AdminConfig = {
month: string
groups: PositionGroupRecord[]
templates: TemplateRecord[]
issues: {
unmatched: Array<{ id: string; displayName: string; departmentName?: string; departmentPath?: string; positionTitle?: string }>
conflicts: Array<{ id: string; displayName: string; groupNames: string[]; departmentName?: string; departmentPath?: string; positionTitle?: string }>
}
}
type ConfigAudit = {
id: string
action?: string
resourceType?: string
targetType?: string
resourceName?: string
actorName?: string
createdAt?: string
summary?: string
}
const adminConfigApi = {
configuration(request: RequestFn, month: string) {
return request<AdminConfig>(`/admin/config?month=${encodeURIComponent(month)}`)
},
auditLogs(request: RequestFn, month: string) {
return request<ConfigAudit[] | { items?: ConfigAudit[] }>(
`/admin/config-audit-logs?month=${encodeURIComponent(month)}&pageSize=20`,
)
},
createPositionGroup(request: RequestFn, input: { name: string; effectiveMonth: string }) {
return request<{ id?: string; group?: { id?: string } }>('/admin/position-groups', {
method: 'POST',
body: JSON.stringify(input),
})
},
previewPositionGroup(request: RequestFn, groupId: string, month: string) {
return request<any>(`/admin/position-groups/${groupId}/preview?month=${encodeURIComponent(month)}`)
},
removePositionGroup(request: RequestFn, groupId: string, reason: string) {
return request<{ id: string; action: 'deleted' | 'archived' }>(`/admin/position-groups/${groupId}`, {
method: 'DELETE',
body: JSON.stringify({ mode: 'auto', reason }),
})
},
removeTemplate(request: RequestFn, templateId: string, reason: string) {
return request<{ id: string; action: 'deleted' | 'archived' }>(`/admin/templates/${templateId}`, {
method: 'DELETE',
body: JSON.stringify({ mode: 'auto', reason }),
})
},
}
function currentMonth() {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
}
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T
}
function newTemplateDefinition(): TemplateDefinition {
return {
code: `template_${Date.now().toString(36)}`,
name: '',
shortName: '',
description: '',
surveyType: 'person_period',
weight: 1,
maxTotal: 100,
questions: [{ id: `score_${Date.now().toString(36)}`, title: '评分项 1', description: '', maxScore: 100 }],
scoreBands: [],
workflow: {
stages: [{
key: 'review',
label: '评价',
order: 0,
reviewers: [{ source: 'manual_selection', label: '评价人' }],
}],
scoring: { mode: 'average_reviewers', stageKeys: ['review'] },
},
}
}
function reviewerSourceLabel(source: ReviewerSource) {
return {
self: '本人',
direct_manager: '直属主管',
fixed_employee: '固定员工',
manual_selection: '发起时选择',
}[source]
}
function surveyTypeLabel(type: TemplateDefinition['surveyType']) {
return {
person_period: '按人员评分',
visual_case: '按作品评分',
item_course: '按课程或事项评分',
}[type]
}
export function PerformanceSurveyAdminConfig({
mode,
request,
onDirtyChange,
month: controlledMonth,
onMonthChange,
onNavigate,
}: {
mode: PerformanceSurveyAdminMode
request: RequestFn
onDirtyChange?: (dirty: boolean) => void
month?: string
onMonthChange?: (month: string) => void
onNavigate?: (mode: PerformanceSurveyAdminMode | 'launch') => void
}) {
const [internalMonth, setInternalMonth] = useState(controlledMonth || currentMonth())
const month = controlledMonth || internalMonth
const [data, setData] = useState<AdminConfig | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const [dirty, setDirty] = useState(false)
const requestSequenceRef = useRef(0)
const { confirmAction, confirmActionDialog } = useConfirmActionDialog()
const setDirtyState = useCallback((next: boolean) => {
setDirty(next)
onDirtyChange?.(next)
}, [onDirtyChange])
const load = useCallback(async (targetMonth: string) => {
const sequence = requestSequenceRef.current + 1
requestSequenceRef.current = sequence
setLoading(true)
setError(null)
try {
const next = await adminConfigApi.configuration(request, targetMonth)
if (requestSequenceRef.current !== sequence) return null
setData(next)
return next
} catch (loadError) {
if (requestSequenceRef.current !== sequence) return null
setError(loadError instanceof Error ? loadError.message : '配置加载失败')
return null
} finally {
if (requestSequenceRef.current === sequence) setLoading(false)
}
}, [request])
useEffect(() => {
void load(month)
return () => {
requestSequenceRef.current += 1
}
}, [load, month])
useEffect(() => {
if (!dirty) return
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault()
event.returnValue = ''
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [dirty])
const confirmDiscard = useCallback(async (message = '当前有未保存修改,确定放弃吗?') => {
if (!dirty) return true
const result = await confirmAction({
title: '放弃未保存的修改?',
description: message,
confirmLabel: '放弃修改',
tone: 'danger',
})
return result.confirmed
}, [confirmAction, dirty])
const changeMonth = async (nextMonth: string) => {
if (!nextMonth || nextMonth === month) return
if (!await confirmDiscard('切换月份会放弃当前未保存修改,确定继续吗?')) return
setDirtyState(false)
setNotice(null)
if (onMonthChange) onMonthChange(nextMonth)
else setInternalMonth(nextMonth)
}
const changed = useCallback(async (message: string) => {
setDirtyState(false)
setNotice(message)
setError(null)
return load(month)
}, [load, month, setDirtyState])
return (
<section className="min-w-0 border border-[#c2c6d8] bg-white" aria-label="考核调研管理控制台">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#e2e2e2] px-4 py-3">
<div>
<h2 className="text-[16px] font-bold text-[#1a1c1c]">
{{
overview: '维护概览',
positions: '岗位管理',
people: '人员分配',
templates: '模板管理',
plans: '岗位考核方案',
}[mode]}
</h2>
<p className="mt-0.5 text-[12px] text-[#5f5e5e]">
{dirty
? `${month} 有未保存修改`
: mode === 'templates'
? '已发布模板会长期使用;只有评分规则变化时才创建新版本'
: mode === 'positions'
? '岗位名称、排序和考核项修改会先保存为草稿,发布后生效'
: mode === 'plans'
? `正在查看 ${month} 的生效方案;已发布方案会持续沿用`
: `正在维护 ${month};已发布版本保持不变`}
</p>
</div>
<label className="flex items-center gap-2 text-[12px] font-semibold text-[#5f5e5e]">
查看月份
<input
type="month"
value={month}
onChange={(event) => void changeMonth(event.target.value)}
className="h-11 rounded border border-[#c2c6d8] px-3 text-[13px] font-semibold text-[#1a1c1c] outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dbe6ff]"
/>
</label>
</div>
{notice && (
<>
<div role="status" aria-live="polite" className="border-b border-[#b3c5ff] bg-[#f8faff] px-4 py-3 text-[13px] font-semibold text-[#0050cb]">
{notice}
</div>
<div className="fixed bottom-[76px] right-4 z-50 max-w-[calc(100vw-2rem)] border border-[#b3c5ff] bg-white px-4 py-3 text-[13px] font-semibold text-[#0050cb] shadow-[0_12px_30px_rgba(0,0,0,0.14)] sm:bottom-5">
{notice}
</div>
</>
)}
{error && (
<div role="alert" className="flex flex-wrap items-center justify-between gap-3 border-b border-[#ffdad6] bg-[#fff6f4] px-4 py-3 text-[13px] font-semibold text-[#93000a]">
<span>{error}</span>
{!data && <button type="button" onClick={() => void load(month)} className="min-h-9 px-2 underline">重新加载</button>}
</div>
)}
{loading && !data && <div role="status" aria-live="polite" className="px-5 py-12 text-center text-[13px] font-semibold text-[#5f5e5e]">配置加载中...</div>}
{data && (
<div className={`p-4 sm:p-5 ${loading ? 'pointer-events-none opacity-60' : ''}`} aria-busy={loading}>
{mode === 'overview' && (
<AdminOverviewWorkspace
data={data}
month={month}
request={request}
onNavigate={onNavigate}
/>
)}
{mode === 'positions' && (
<PositionConfigWorkspace
data={data}
month={month}
request={request}
onError={setError}
onChanged={changed}
onDirtyChange={setDirtyState}
confirmDiscard={confirmDiscard}
confirmAction={confirmAction}
dirty={dirty}
/>
)}
{mode === 'people' && (
<PerformanceSurveyAdminPeople
month={month}
groups={data.groups}
request={request}
onError={setError}
onSuccess={(message) => {
setNotice(message)
setError(null)
}}
/>
)}
{mode === 'templates' && (
<TemplateConfigWorkspace
data={data}
month={month}
request={request}
onError={setError}
onChanged={changed}
onDirtyChange={setDirtyState}
confirmDiscard={confirmDiscard}
confirmAction={confirmAction}
dirty={dirty}
/>
)}
{mode === 'plans' && (
<MonthlyPlanWorkspace
data={data}
month={month}
request={request}
onError={setError}
onChanged={changed}
/>
)}
</div>
)}
{confirmActionDialog}
</section>
)
}
function AdminOverviewWorkspace({
data,
month,
request,
onNavigate,
}: {
data: AdminConfig
month: string
request: RequestFn
onNavigate?: (mode: PerformanceSurveyAdminMode | 'launch') => void
}) {
const [audits, setAudits] = useState<ConfigAudit[]>([])
const [auditError, setAuditError] = useState<string | null>(null)
const [auditLoading, setAuditLoading] = useState(true)
const activeGroups = data.groups.filter((group) => group.status !== 'disabled')
const disabledGroups = data.groups.filter((group) => group.status === 'disabled').length
const draftTemplates = data.templates.flatMap((template) => template.versions).filter((version) => version.status === 'draft').length
const publishedTemplates = data.templates.flatMap((template) => template.versions).filter((version) => version.status === 'published').length
const availablePublishedTemplates = data.templates
.flatMap((template) => template.versions)
.filter((version) => (
version.status === 'published'
&& (!version.effectiveMonth || version.effectiveMonth <= month)
)).length
const effectivePlanCount = activeGroups.filter((group) => group.effectivePlan).length
const inheritedPlanCount = activeGroups.filter((group) => (
group.effectivePlan && group.effectivePlan.effectiveMonth < month
)).length
const draftPlanCount = activeGroups.filter((group) => group.draftPlan).length
const hasPeopleIssues = data.issues.unmatched.length > 0 || data.issues.conflicts.length > 0
const positionsReady = activeGroups.length > 0
const templatesReady = availablePublishedTemplates > 0
const plansReady = activeGroups.length > 0 && effectivePlanCount === activeGroups.length
const launchReady = positionsReady && templatesReady && plansReady && data.issues.conflicts.length === 0
const steps = [
{
id: 'people',
label: '确认人员资料',
status: data.issues.conflicts.length > 0 ? 'blocked' : hasPeopleIssues ? 'attention' : 'done',
summary: data.issues.conflicts.length > 0
? `${data.issues.conflicts.length} 人同时匹配多个岗位,必须先处理`
: data.issues.unmatched.length > 0
? `${data.issues.unmatched.length} 人待确认:分配岗位,或明确标记为不参与`
: '人员岗位归属没有待处理异常',
action: '处理人员',
target: 'people' as const,
},
{
id: 'positions',
label: '确认岗位',
status: positionsReady ? 'done' : 'attention',
summary: activeGroups.length === 0
? '还没有可用岗位'
: `${activeGroups.length} 个岗位可用;人员归属请在“人员分配”中维护${disabledGroups > 0 ? `,另有 ${disabledGroups} 个已删除` : ''}`,
action: '管理岗位',
target: 'positions' as const,
},
{
id: 'templates',
label: '准备考核模板',
status: templatesReady ? (draftTemplates > 0 ? 'attention' : 'done') : 'blocked',
summary: templatesReady
? `${availablePublishedTemplates} 个版本可用于 ${month}${draftTemplates > 0 ? `,${draftTemplates} 个草稿待确认` : ''}`
: `还没有可用于 ${month} 的已发布模板,岗位考核方案无法完成${publishedTemplates > 0 ? `(另有 ${publishedTemplates} 个其它月份版本)` : ''}`,
action: '管理模板',
target: 'templates' as const,
},
{
id: 'plans',
label: '确认岗位考核方案',
status: plansReady ? 'done' : draftPlanCount > 0 ? 'attention' : 'blocked',
summary: `${effectivePlanCount}/${activeGroups.length} 个在用岗位已有生效方案${inheritedPlanCount > 0 ? `,其中 ${inheritedPlanCount} 个沿用较早版本` : ''}${draftPlanCount > 0 ? `;另有 ${draftPlanCount} 个草稿待确认` : ''}`,
action: '配置方案',
target: 'plans' as const,
},
{
id: 'launch',
label: '发起调研',
status: launchReady ? 'ready' : 'blocked',
summary: launchReady ? `${month} 的岗位、模板和方案已可用于发起调研` : '完成上面的必要配置后再发起,避免人员或模板选错',
action: '去发起调研',
target: 'launch' as const,
},
]
const loadAudits = useCallback(async () => {
setAuditLoading(true)
setAuditError(null)
try {
const payload = await adminConfigApi.auditLogs(request, month)
setAudits(Array.isArray(payload) ? payload : Array.isArray(payload?.items) ? payload.items : [])
} catch (error) {
setAudits([])
setAuditError(error instanceof Error ? error.message : '配置记录加载失败')
} finally {
setAuditLoading(false)
}
}, [month, request])
useEffect(() => {
void loadAudits()
}, [loadAudits])
return (
<section aria-labelledby="performance-survey-admin-overview-title">
<div className="border-b border-[#e2e2e2] pb-4">
<h2 id="performance-survey-admin-overview-title" className="text-[18px] font-bold text-[#1a1c1c]">
{month} 配置概览
</h2>
<p className="mt-1 max-w-3xl text-[13px] leading-5 text-[#5f5e5e]">
先处理人员匹配问题,再检查模板与岗位考核方案。已发布方案会持续沿用,无需每月重复配置。
</p>
</div>
<div className="mt-4 border border-[#c2c6d8]">
<div className="border-b border-[#e2e2e2] bg-[#f7f7f7] px-4 py-3">
<h3 className="text-[14px] font-bold text-[#1a1c1c]">本月上线准备</h3>
<p className="mt-1 text-[12px] leading-5 text-[#5f5e5e]">按顺序完成;每一步都可以直接进入对应管理页面。</p>
</div>
<ol className="divide-y divide-[#e2e2e2]">
{steps.map((step, index) => {
const statusLabel = {
done: '已完成',
attention: '待确认',
blocked: '未完成',
ready: '可发起',
}[step.status]
const statusClass = {
done: 'bg-[#eaf7ef] text-[#16794b]',
attention: 'bg-[#fff4da] text-[#7a4b00]',
blocked: 'bg-[#f3f3f3] text-[#5f5e5e]',
ready: 'bg-[#e9efff] text-[#0050cb]',
}[step.status]
return (
<li key={step.id} className="grid gap-3 px-4 py-4 sm:grid-cols-[40px_minmax(0,1fr)_auto] sm:items-center">
<div className="flex h-8 w-8 items-center justify-center rounded-full border border-[#c2c6d8] text-[13px] font-bold text-[#1a1c1c]" aria-hidden="true">
{index + 1}
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h4 className="text-[14px] font-bold text-[#1a1c1c]">{step.label}</h4>
<span className={`rounded-full px-2 py-1 text-[11px] font-bold ${statusClass}`}>{statusLabel}</span>
</div>
<p className="mt-1 text-[12px] leading-5 text-[#5f5e5e]">{step.summary}</p>
</div>
<button
type="button"
onClick={() => onNavigate?.(step.target)}
className="min-h-11 w-full rounded border border-[#1a1c1c] px-4 text-[12px] font-bold text-[#1a1c1c] hover:bg-[#1a1c1c] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2 sm:w-auto"
>
{step.action}
</button>
</li>
)
})}
</ol>
</div>
{(data.issues.unmatched.length > 0 || data.issues.conflicts.length > 0) && (
<div className="mt-4 border border-[#e4b86a] bg-[#fffaf0] px-4 py-3 text-[13px] leading-5 text-[#6f4700]">
“未匹配”不等于必须参与考核。请逐一确认:需要参与的人分配岗位,不参与的人明确标记为“不参与”;重复匹配人员必须先消除冲突。
</div>
)}
<section className="mt-6" aria-labelledby="performance-survey-admin-audit-title">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#e2e2e2] pb-2">
<h3 id="performance-survey-admin-audit-title" className="text-[14px] font-bold text-[#1a1c1c]">最近配置记录</h3>
<button type="button" onClick={() => void loadAudits()} className="min-h-9 px-2 text-[12px] font-bold text-[#0050cb] hover:underline">
刷新
</button>
</div>
{auditLoading && <div role="status" aria-live="polite" className="py-6 text-[13px] font-semibold text-[#5f5e5e]">记录加载中...</div>}
{auditError && (
<div role="note" className="py-4 text-[13px] text-[#8a5700]">
配置记录暂不可用:{auditError}
</div>
)}
{!auditLoading && !auditError && (
<div className="overflow-x-auto">
<table className="w-full min-w-[620px] border-collapse text-left">
<thead>
<tr className="border-b border-[#e2e2e2] text-[12px] font-bold text-[#5f5e5e]">
<th scope="col" className="px-2 py-3">时间</th>
<th scope="col" className="px-2 py-3">操作人</th>
<th scope="col" className="px-2 py-3">对象</th>
<th scope="col" className="px-2 py-3">变更</th>
</tr>
</thead>
<tbody className="divide-y divide-[#eeeeee]">
{audits.map((audit) => (
<tr key={audit.id} className="text-[13px] text-[#5f5e5e]">
<td className="px-2 py-3">{audit.createdAt ? new Date(audit.createdAt).toLocaleString('zh-CN') : '-'}</td>
<td className="px-2 py-3">{audit.actorName || '-'}</td>
<td className="px-2 py-3">{audit.resourceName || audit.resourceType || audit.targetType || '-'}</td>
<td className="px-2 py-3">{audit.summary || audit.action || '-'}</td>
</tr>
))}
{audits.length === 0 && (
<tr>
<td colSpan={4} className="px-2 py-8 text-center text-[13px] text-[#5f5e5e]">暂无配置记录</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</section>
</section>
)
}
function PositionConfigWorkspace({
data,
month,
request,
onError,
onChanged,
onDirtyChange,
confirmDiscard,
confirmAction,
dirty,
}: {
data: AdminConfig
month: string
request: RequestFn
onError: (message: string | null) => void
onChanged: (message: string) => Promise<AdminConfig | null>
onDirtyChange: (dirty: boolean) => void
confirmDiscard: (message?: string) => Promise<boolean>
confirmAction: ConfirmActionFn
dirty: boolean
}) {
const [selectedId, setSelectedId] = useState(data.groups[0]?.id || '')
const selected = data.groups.find((group) => group.id === selectedId) || data.groups[0]
const [name, setName] = useState(selected?.name || '')
const [sortOrder, setSortOrder] = useState(selected?.sortOrder || 0)
const [templateVersionIds, setTemplateVersionIds] = useState<string[]>(
selected?.draftPlan?.templateVersionIds || selected?.effectivePlan?.templateVersionIds || [],
)
const [templateSearch, setTemplateSearch] = useState('')
const [saving, setSaving] = useState(false)
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
const editorRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!selected) return
setName(selected.name)
setSortOrder(selected.sortOrder)
setTemplateVersionIds(selected.draftPlan?.templateVersionIds || selected.effectivePlan?.templateVersionIds || [])
setTemplateSearch('')
onDirtyChange(false)
}, [onDirtyChange, selected])
const markDirty = () => onDirtyChange(true)
const publishedVersions = useMemo(() => data.templates.flatMap((template) => (
template.versions
.filter((version) => (
version.status === 'published'
&& (!version.effectiveMonth || version.effectiveMonth <= month)
))
.map((version) => ({ template, version }))
)), [data.templates, month])
const versionById = useMemo(
() => new Map(publishedVersions.map((item) => [item.version.id, item])),
[publishedVersions],
)
const searchResults = useMemo(() => {
const keyword = templateSearch.trim().toLocaleLowerCase('zh-CN')
if (!keyword) return []
const selectedTemplateIds = new Set(
templateVersionIds
.map((id) => versionById.get(id)?.template.id)
.filter(Boolean),
)
return publishedVersions.filter(({ template, version }) => (
!templateVersionIds.includes(version.id)
&& !selectedTemplateIds.has(template.id)
&& `${version.definition.name} ${template.name}`.toLocaleLowerCase('zh-CN').includes(keyword)
)).slice(0, 8)
}, [publishedVersions, templateSearch, templateVersionIds, versionById])
if (!selected) return <div className="px-5 py-12 text-center text-sm text-[#727687]">暂无岗位组</div>
const save = async () => {
setSaving(true)
onError(null)
try {
await request(`/admin/position-groups/${selected.id}`, {
method: 'PATCH',
body: JSON.stringify({ name, sortOrder, templateVersionIds, effectiveMonth: month }),
})
await onChanged('岗位和考核项修改已保存,发布后生效')
} catch (actionError) {
onError(actionError instanceof Error ? actionError.message : '岗位方案保存失败')
} finally {
setSaving(false)
}
}
const createGroup = async () => {
if (!newName.trim()) {
onError('请填写岗位名称')
return
}
setSaving(true)
onError(null)
try {
const created = await adminConfigApi.createPositionGroup(request, {
name: newName.trim(),
effectiveMonth: month,
})
const next = await onChanged(`岗位“${newName.trim()}”已创建`)
const nextSelectedId = created.id || created.group?.id || next?.groups.find((group) => group.name === newName.trim())?.id
if (nextSelectedId) setSelectedId(nextSelectedId)
setCreating(false)
setNewName('')
} catch (actionError) {
onError(actionError instanceof Error ? actionError.message : '岗位创建失败')
} finally {
setSaving(false)
}
}
const removeGroup = async () => {
if (!selected) return
const memberWarning = selected.memberCount > 0
? `当前仍有 ${selected.memberCount} 人属于该岗位,删除后这些人员会变为待分配。`
: '该岗位当前没有人员。'
await confirmAction({
title: `删除岗位“${selected.name}”?`,
description: (
<div className="grid gap-1">
<p>{memberWarning}</p>
<p>历史调研记录会保留,但该岗位将不再出现在当前管理列表中。</p>
{dirty && <p className="font-semibold text-[#93000a]">当前未保存的岗位修改也会同时放弃。</p>}
</div>
),
confirmLabel: '删除岗位',
loadingLabel: '删除中...',
tone: 'danger',
reason: {
label: '删除原因',
placeholder: '例如:岗位合并、岗位不再参与考核',
required: true,
},
action: async (reason) => {
setSaving(true)
onError(null)
try {
const removeResult = await adminConfigApi.removePositionGroup(request, selected.id, reason)
const next = await onChanged(
removeResult.action === 'deleted'
? `岗位“${selected.name}”已删除`
: `岗位“${selected.name}”已从管理列表删除,历史记录已保留`,
)
setSelectedId(next?.groups[0]?.id || '')
} finally {
setSaving(false)
}
},
})
}
const addTemplateVersion = (versionId: string) => {
setTemplateVersionIds((current) => current.includes(versionId) ? current : [...current, versionId])
setTemplateSearch('')
markDirty()
}
const removeTemplateVersion = (versionId: string) => {
setTemplateVersionIds((current) => current.filter((id) => id !== versionId))
markDirty()
}
const moveTemplateVersion = (index: number, direction: -1 | 1) => {
const targetIndex = index + direction
if (targetIndex < 0 || targetIndex >= templateVersionIds.length) return
setTemplateVersionIds((current) => {
const next = [...current]
const [moved] = next.splice(index, 1)
next.splice(targetIndex, 0, moved)
return next
})
markDirty()
}
const selectGroup = async (groupId: string) => {
if (groupId === selected?.id) return
if (!await confirmDiscard('切换岗位会放弃当前未保存修改,确定继续吗?')) return
onDirtyChange(false)
setSelectedId(groupId)
if (typeof window.matchMedia === 'function' && window.matchMedia('(max-width: 767px)').matches) {
window.requestAnimationFrame(() => editorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }))
}
}
return (
<div className="grid min-h-[620px] md:grid-cols-[240px_minmax(0,1fr)]">
<aside className="border-b border-[#e2e2e2] md:border-b-0 md:border-r">
<div className="border-b border-[#c2c6d8] p-3">
<button
type="button"
onClick={() => setCreating((current) => !current)}
className="min-h-11 w-full rounded bg-[#1a1c1c] px-3 text-[13px] font-bold text-white hover:bg-black"
>
{creating ? '取消新增' : '新增岗位'}
</button>
{creating && (
<div className="mt-3 grid gap-2">
<input
aria-label="新岗位名称"
value={newName}
onChange={(event) => setNewName(event.target.value)}
placeholder="岗位名称"
autoFocus
className="h-11 rounded border border-[#c2c6d8] px-3 text-[13px] outline-none focus:border-[#0050cb]"
/>
<button type="button" disabled={saving} onClick={() => void createGroup()} className="min-h-11 rounded bg-[#0050cb] px-3 text-[13px] font-bold text-white disabled:opacity-50">
确认创建
</button>
</div>
)}
</div>
{data.groups.map((group) => (
<button
key={group.id}
type="button"
onClick={() => void selectGroup(group.id)}
className={`grid w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b border-[#eeeeee] px-4 py-3 text-left ${selected.id === group.id ? 'bg-[#f1f4ff]' : 'hover:bg-[#fafafa]'}`}
>
<span className="min-w-0">
<span className="block truncate text-[13px] font-bold text-[#1a1c1c]">{group.name}</span>
<span className="mt-0.5 block text-[11px] text-[#5f5e5e]">
{group.memberCount} 人 · {group.status === 'ready' ? '已配置' : group.status === 'disabled' ? '已归档' : '待配置'}
</span>
</span>
<span className={`h-2 w-2 rounded-full ${group.status === 'ready' ? 'bg-[#16794b]' : group.status === 'disabled' ? 'bg-[#8a8a8a]' : 'bg-[#b26a00]'}`} />
</button>
))}
</aside>
<div ref={editorRef} className="min-w-0 scroll-mt-4 p-4 sm:p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 border-b border-[#e2e2e2] pb-3">
<div>
<div className="text-[12px] font-bold text-[#0050cb]">正在编辑:{selected.name}</div>
<p className="mt-1 text-[11px] leading-5 text-[#5f5e5e]">岗位名称、排序和考核项保存后进入草稿,需要发布岗位考核方案后才会生效。</p>
</div>
<button type="button" disabled={saving} onClick={() => void removeGroup()} className="min-h-10 rounded border border-[#f0b8b8] px-3 text-[12px] font-bold text-[#b42318] hover:bg-[#fff5f5]">
删除岗位
</button>
</div>
<div className="grid min-w-0 gap-4 lg:grid-cols-[minmax(0,1fr)_140px]">
<AdminField label="岗位名称" value={name} onChange={(value) => { setName(value); markDirty() }} />
<AdminField label="排序" value={String(sortOrder)} onChange={(value) => { setSortOrder(Number(value) || 0); markDirty() }} type="number" />
</div>
<div className="mt-6 border-t border-[#eeeeee] pt-5">
<h3 className="text-[14px] font-bold text-[#1a1c1c]">岗位考核项</h3>
<p className="mt-1 text-[12px] leading-5 text-[#5f5e5e]">
在这里选择该岗位需要参加的考核。保存后会形成草稿,再到“岗位考核方案”检查并发布。
</p>
<div className="relative mt-4 max-w-xl">
<label className="grid gap-1 text-[12px] font-semibold text-[#5f5e5e]">
添加考核项
<input
value={templateSearch}
onChange={(event) => setTemplateSearch(event.target.value)}
placeholder="输入模板名称搜索"
className="h-11 w-full rounded border border-[#c2c6d8] px-3 text-[13px] outline-none focus:border-[#0050cb]"
/>
</label>
{templateSearch.trim() && (
<div className="absolute z-30 mt-1 max-h-64 w-full overflow-y-auto border border-[#c2c6d8] bg-white shadow-[0_4px_8px_rgba(0,0,0,0.12)]">
{searchResults.map(({ template, version }) => (
<button
key={version.id}
type="button"
onClick={() => addTemplateVersion(version.id)}
className="flex min-h-11 w-full items-center justify-between gap-3 border-b border-[#eeeeee] px-3 py-2 text-left last:border-b-0 hover:bg-[#f4f6ff]"
>
<span className="min-w-0">
<span className="block truncate text-[13px] font-bold text-[#1a1c1c]">{version.definition.name}</span>
</span>
<span className="shrink-0 text-[11px] text-[#5f5e5e]">v{version.version}</span>
</button>
))}
{searchResults.length === 0 && (
<div className="px-3 py-4 text-center text-[12px] text-[#727687]">没有可添加的匹配模板</div>
)}
</div>
)}
</div>
<div className="mt-4 grid gap-2">
{templateVersionIds.map((id, index) => {
const item = versionById.get(id)
return (
<div key={id} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border border-[#c2c6d8] px-3 py-3">
<div className="min-w-0">
<div className="truncate text-[13px] font-bold text-[#1a1c1c]">{item?.version.definition.name || item?.template.name || '历史模板'}</div>
<div className="mt-0.5 text-[11px] text-[#727687]">
第 {index + 1} 项{item ? ` · v${item.version.version}` : ''}
</div>
</div>
<div className="flex flex-wrap justify-end gap-1">
<button type="button" disabled={index === 0} onClick={() => moveTemplateVersion(index, -1)} className="min-h-10 rounded px-3 text-[12px] font-bold text-[#0050cb] disabled:text-[#aaaaaa]">上移</button>
<button type="button" disabled={index === templateVersionIds.length - 1} onClick={() => moveTemplateVersion(index, 1)} className="min-h-10 rounded px-3 text-[12px] font-bold text-[#0050cb] disabled:text-[#aaaaaa]">下移</button>
<button type="button" onClick={() => removeTemplateVersion(id)} className="min-h-10 rounded px-3 text-[12px] font-bold text-[#b42318] hover:bg-[#fff5f5]">移除</button>
</div>
</div>
)
})}
{templateVersionIds.length === 0 && (
<div className="border border-dashed border-[#c2c6d8] px-4 py-7 text-center text-[12px] text-[#727687]">
尚未选择考核项,请通过上方搜索框添加
</div>
)}
</div>
</div>
<div className="sticky bottom-[68px] z-20 -mx-4 mt-6 flex flex-wrap items-center justify-between gap-2 border-t border-[#c2c6d8] bg-white/95 px-4 py-3 shadow-[0_-4px_10px_rgba(0,0,0,0.06)] backdrop-blur sm:bottom-0 sm:mx-0 sm:justify-end sm:shadow-none">
<span className="text-[12px] font-semibold text-[#5f5e5e] sm:mr-auto">保存后请前往“岗位考核方案”发布生效</span>
<button type="button" disabled={saving || selected.status === 'disabled'} onClick={() => void save()} className="min-h-11 rounded bg-[#0050cb] px-5 text-[13px] font-bold text-white disabled:opacity-50">
{saving ? '保存中...' : '保存修改'}
</button>
</div>
</div>
</div>
)
}
function TemplateConfigWorkspace({
data,
month,
request,
onError,
onChanged,
onDirtyChange,
confirmDiscard,
confirmAction,
dirty,
}: {
data: AdminConfig
month: string
request: RequestFn
onError: (message: string | null) => void
onChanged: (message: string) => Promise<AdminConfig | null>
onDirtyChange: (dirty: boolean) => void
confirmDiscard: (message?: string) => Promise<boolean>
confirmAction: ConfirmActionFn
dirty: boolean
}) {
const firstTemplate = data.templates[0]
const [selectedTemplateId, setSelectedTemplateId] = useState(firstTemplate?.id || 'new')
const selectedTemplate = data.templates.find((template) => template.id === selectedTemplateId) || null
const selectedVersion = selectedTemplate?.versions.find((version) => version.status === 'draft')
|| selectedTemplate?.versions[0]
|| null
const [versionId, setVersionId] = useState<string | null>(selectedVersion?.id || null)
const [definition, setDefinition] = useState<TemplateDefinition>(() => clone(selectedVersion?.definition || newTemplateDefinition()))
const [editorStep, setEditorStep] = useState<'basics' | 'questions' | 'workflow'>('basics')
const [saving, setSaving] = useState(false)
const editorRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const nextVersion = selectedTemplate?.versions.find((version) => version.status === 'draft')
|| selectedTemplate?.versions[0]
|| null
setVersionId(nextVersion?.id || null)
setDefinition(clone(nextVersion?.definition || newTemplateDefinition()))
setEditorStep('basics')
onDirtyChange(false)
}, [onDirtyChange, selectedTemplate])
const version = selectedTemplate?.versions.find((item) => item.id === versionId) || null
const editable = !version || version.status === 'draft'
const questionTotal = definition.questions.reduce(
(sum, question) => sum + Number(question.maxScore || definition.questionMaxScore || 20),
0,
)
const calculatedTemplateTotal = questionTotal * (
definition.surveyType === 'visual_case' ? Math.max(1, Number(definition.requiredWorkItemCount || 1)) : 1
)
const templateDefinitionReady = definition.questions.length > 0
&& Math.round(calculatedTemplateTotal * 100) === Math.round(Number(definition.maxTotal || 0) * 100)
const updateDefinition = (next: TemplateDefinition | ((current: TemplateDefinition) => TemplateDefinition)) => {
setDefinition(next)
onDirtyChange(true)
}
const save = async () => {
setSaving(true)
onError(null)
try {
const saved = versionId
? await request<{ id: string }>(`/admin/templates/${versionId}`, {
method: 'PATCH',
body: JSON.stringify({ definition }),
})
: await request<{ id: string }>('/admin/templates', {
method: 'POST',
body: JSON.stringify({ definition }),
})
const next = await onChanged('模板草稿已保存')
const nextTemplate = next?.templates.find((template) => template.versions.some((item) => item.id === saved.id))
if (nextTemplate) setSelectedTemplateId(nextTemplate.id)
setVersionId(saved.id)
onDirtyChange(false)
} catch (actionError) {
onError(actionError instanceof Error ? actionError.message : '模板草稿保存失败')
} finally {
setSaving(false)
}
}
const cloneVersion = async () => {
if (!versionId || !await confirmDiscard()) return
setSaving(true)
onError(null)
try {
const draft = await request<{ id: string }>(`/admin/templates/${versionId}/clone`, { method: 'POST', body: '{}' })
const next = await onChanged('已创建新的模板草稿')
const nextTemplate = next?.templates.find((template) => template.versions.some((item) => item.id === draft.id))
if (nextTemplate) setSelectedTemplateId(nextTemplate.id)
setVersionId(draft.id)
} catch (actionError) {
onError(actionError instanceof Error ? actionError.message : '模板草稿创建失败')
} finally {
setSaving(false)
}
}
const publish = async () => {
if (!versionId) return
if (dirty) {
onError('请先保存模板草稿,再执行发布')
return
}
await confirmAction({
title: `发布模板“${definition.name}”?`,
description: (
<div className="grid gap-1">
<p>模板将从 {month} 起供岗位考核方案使用。</p>
<p>发布后的版本不可直接修改;后续调整需要创建新的草稿版本。</p>
</div>
),
confirmLabel: `发布至 ${month}`,
loadingLabel: '发布中...',
action: async () => {
setSaving(true)
onError(null)
try {
await request(`/admin/templates/${versionId}/publish`, {
method: 'POST',
body: JSON.stringify({ effectiveMonth: month }),
})
await onChanged(`${definition.name} 已从 ${month} 起发布`)
} finally {
setSaving(false)
}
},
})
}
const removeTemplate = async () => {
if (!selectedTemplate) return
const displayName = version?.definition.name || selectedTemplate.versions[0]?.definition.name || selectedTemplate.name
await confirmAction({
title: `删除模板“${displayName}”?`,
description: (
<div className="grid gap-1">
<p>历史调研中的模板和评分数据会继续保留。</p>
<p>如果岗位仍在使用该模板,需要先从岗位考核项中移除后才能删除。</p>
{dirty && <p className="font-semibold text-[#93000a]">当前未保存的模板修改也会同时放弃。</p>}
</div>
),
confirmLabel: '删除模板',
loadingLabel: '删除中...',
tone: 'danger',
reason: {
label: '删除原因',
placeholder: '例如:模板重复、考核规则已停用',
required: true,
},
action: async (reason) => {
setSaving(true)
onError(null)
try {
const removeResult = await adminConfigApi.removeTemplate(request, selectedTemplate.id, reason)
const next = await onChanged(
removeResult.action === 'deleted'
? `模板“${displayName}”已删除`
: `模板“${displayName}”已从模板列表删除,历史记录已保留`,
)
setSelectedTemplateId(next?.templates[0]?.id || 'new')
} finally {
setSaving(false)
}
},
})
}
const selectTemplate = async (templateId: string) => {
if (templateId === selectedTemplateId) return
if (!await confirmDiscard('切换模板会放弃当前未保存修改,确定继续吗?')) return
onDirtyChange(false)
setSelectedTemplateId(templateId)
if (typeof window.matchMedia === 'function' && window.matchMedia('(max-width: 767px)').matches) {
window.requestAnimationFrame(() => editorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }))
}
}
const selectVersion = async (nextVersion: TemplateVersion) => {
if (nextVersion.id === versionId) return
if (!await confirmDiscard('切换版本会放弃当前未保存修改,确定继续吗?')) return
onDirtyChange(false)
setVersionId(nextVersion.id)
setDefinition(clone(nextVersion.definition))
setEditorStep('basics')
}
return (
<div className="grid min-h-[680px] md:grid-cols-[260px_minmax(0,1fr)]">
<aside className="border-b border-[#e2e2e2] md:border-b-0 md:border-r">
<div className="border-b border-[#c2c6d8] p-3">
<button
type="button"
onClick={() => void selectTemplate('new')}
className="min-h-11 w-full rounded bg-[#1a1c1c] px-4 text-center text-[13px] font-bold text-white hover:bg-black"
>
新建模板
</button>
</div>
{data.templates.map((template) => (
<button
key={template.id}
type="button"
onClick={() => void selectTemplate(template.id)}
className={`w-full border-b border-[#eeeeee] px-4 py-3 text-left ${selectedTemplateId === template.id ? 'bg-[#f1f4ff]' : 'hover:bg-[#fafafa]'}`}
>
<span className="block truncate text-[13px] font-bold text-[#1a1c1c]">{template.versions[0]?.definition.name || template.name}</span>
<span className="mt-0.5 block text-[11px] text-[#727687]">{template.versions.length} 个版本</span>
</button>
))}
</aside>
<div ref={editorRef} className="min-w-0 scroll-mt-4 p-4 sm:p-5">
<div className="mb-4 text-[12px] font-bold text-[#0050cb] md:hidden">正在编辑:{definition.name || '新建模板'}</div>
{selectedTemplate && (
<div className="mb-4 flex flex-wrap items-center gap-2 border-b border-[#eeeeee] pb-3">
{selectedTemplate.versions.map((item) => (
<button
key={item.id}
type="button"
onClick={() => void selectVersion(item)}
className={`min-h-10 rounded border px-3 text-left text-[12px] font-bold ${versionId === item.id ? 'border-[#0050cb] bg-[#f1f4ff] text-[#0050cb]' : 'border-[#e2e2e2] text-[#5f5e5e]'}`}
>
<span className="block">v{item.version} · {item.status === 'draft' ? '草稿' : item.status === 'published' ? '已发布' : '已归档'}</span>
{item.effectiveMonth && <span className="mt-0.5 block text-[10px] font-semibold opacity-80">{item.effectiveMonth} 起</span>}
</button>
))}
<button
type="button"
disabled={saving}
onClick={() => void removeTemplate()}
className="ml-auto min-h-10 rounded border border-[#f0b8b8] px-3 text-[12px] font-bold text-[#b42318] hover:bg-[#fff5f5] disabled:opacity-50"
>
删除模板
</button>
</div>
)}
<section aria-label="模板摘要" className="mb-5 border border-[#c2c6d8] bg-[#fafafa] px-4 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[11px] font-bold text-[#727687]">当前模板</div>
<div className="mt-1 truncate text-[16px] font-bold text-[#1a1c1c]">{definition.name || '未命名模板'}</div>
<div className="mt-1 text-[12px] leading-5 text-[#5f5e5e]">
{surveyTypeLabel(definition.surveyType)} · {definition.questions.length} 个评分项 · {definition.workflow.stages.length} 个评审阶段
</div>
</div>
<div className="flex gap-5 text-right">
<div>
<div className="text-[18px] font-bold text-[#1a1c1c]">
{Number(definition.maxTotal || 0)}
</div>
<div className="text-[11px] font-semibold text-[#727687]">模板总分</div>
</div>
<div>
<div className="text-[18px] font-bold text-[#1a1c1c]">{version ? `v${version.version}` : '新建'}</div>
<div className="text-[11px] font-semibold text-[#727687]">{editable ? '草稿可编辑' : '已发布只读'}</div>
</div>
</div>
</div>
</section>
<nav aria-label="模板编辑步骤" className="mb-5 grid grid-cols-3 border border-[#c2c6d8]">
{([
['basics', '1. 基本信息'],
['questions', '2. 评分项'],
['workflow', '3. 评审与计分'],
] as const).map(([step, label]) => (
<button
key={step}
type="button"
aria-current={editorStep === step ? 'step' : undefined}
onClick={() => setEditorStep(step)}
className={`min-h-11 border-r border-[#c2c6d8] px-2 text-[12px] font-bold last:border-r-0 ${
editorStep === step ? 'bg-[#1a1c1c] text-white' : 'bg-white text-[#5f5e5e] hover:bg-[#f4f4f4]'
}`}
>
{label}
</button>
))}
</nav>
{editorStep === 'basics' && (
<section aria-labelledby="template-basics-heading">
<div className="mb-4">
<h3 id="template-basics-heading" className="text-[15px] font-bold text-[#1a1c1c]">基本信息</h3>
<p className="mt-1 text-[12px] leading-5 text-[#727687]">管理员和员工都会看到这些名称与说明。</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<AdminField
label="模板名称"
value={definition.name}
onChange={(value) => updateDefinition((current) => ({ ...current, name: value, shortName: value }))}
disabled={!editable}
/>
<label className="grid gap-1 text-[12px] font-semibold text-[#5f5e5e]">
评分对象
<select
value={definition.surveyType}
disabled={!editable}
onChange={(event) => updateDefinition((current) => ({ ...current, surveyType: event.target.value as TemplateDefinition['surveyType'] }))}
className="h-11 rounded border border-[#c2c6d8] bg-white px-3 text-[13px] font-semibold text-[#1a1c1c] outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4]"
>
<option value="person_period">员工表现(评价一段时间内的工作表现)</option>
<option value="visual_case">作品或案例(需要提交图片或案例)</option>
<option value="item_course">课程或事项(评价一次活动或任务)</option>
</select>
</label>
{definition.surveyType === 'visual_case' && (
<AdminField
label="每人需要提交的作品数量"
value={String(definition.requiredWorkItemCount || 3)}
type="number"
disabled={!editable}
onChange={(value) => updateDefinition((current) => ({ ...current, requiredWorkItemCount: Math.max(1, Number(value) || 1), workItemImageRequired: true }))}
/>
)}
<label className="grid gap-1 sm:col-span-2 text-[12px] font-semibold text-[#5f5e5e]">
给发起人和评分人的说明
<textarea
value={definition.description}
disabled={!editable}
onChange={(event) => updateDefinition((current) => ({ ...current, description: event.target.value }))}
className="min-h-24 rounded border border-[#e2e2e2] px-3 py-2 text-[13px] font-normal text-[#1a1c1c] outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4]"
/>
</label>
</div>
</section>
)}
{editorStep === 'questions' && (
<QuestionEditor definition={definition} editable={editable} onChange={updateDefinition} />
)}
{editorStep === 'workflow' && (
<WorkflowEditor definition={definition} editable={editable} request={request} onChange={updateDefinition} />
)}
<div className="sticky bottom-[68px] z-20 -mx-4 mt-6 flex flex-wrap justify-end gap-2 border-t border-[#c2c6d8] bg-white/95 px-4 py-3 shadow-[0_-4px_10px_rgba(0,0,0,0.06)] backdrop-blur sm:bottom-0 sm:mx-0 sm:shadow-none">
{!editable && <button type="button" disabled={saving} onClick={() => void cloneVersion()} className="min-h-11 rounded bg-[#0050cb] px-4 text-[13px] font-bold text-white disabled:opacity-50">修改模板</button>}
{editable && <button type="button" disabled={saving} onClick={() => void save()} className="min-h-11 rounded border border-[#1a1c1c] px-4 text-[13px] font-bold text-[#1a1c1c] disabled:opacity-50">保存草稿</button>}
{editable && versionId && (
<button
type="button"
disabled={saving || !templateDefinitionReady}
onClick={() => void publish()}
className="min-h-11 rounded bg-[#0050cb] px-4 text-[13px] font-bold text-white disabled:opacity-50"
title={templateDefinitionReady ? undefined : '请先添加评分项,并确保评分项合计与模板总分一致'}
>
发布至 {month}
</button>
)}
</div>
</div>
</div>
)
}
function MonthlyPlanWorkspace({
data,
month,
request,
onError,
onChanged,
}: {
data: AdminConfig
month: string
request: RequestFn
onError: (message: string | null) => void
onChanged: (message: string) => Promise<AdminConfig | null>
}) {
const [selectedGroupId, setSelectedGroupId] = useState(data.groups.find((group) => group.status !== 'disabled')?.id || data.groups[0]?.id || '')
const group = data.groups.find((item) => item.id === selectedGroupId) || data.groups[0]
const [templateVersionIds, setTemplateVersionIds] = useState<string[]>(
group?.draftPlan?.templateVersionIds || group?.effectivePlan?.templateVersionIds || [],
)
const [saving, setSaving] = useState(false)
const [preview, setPreview] = useState<any | null>(null)
const publishedVersions = useMemo(() => data.templates.flatMap((template) => (
template.versions
.filter((version) => version.status === 'published')
.map((version) => ({ template, version }))
)), [data.templates])
const versionById = useMemo(() => new Map(publishedVersions.map((item) => [item.version.id, item])), [publishedVersions])
useEffect(() => {
if (!group) return
setTemplateVersionIds(group.draftPlan?.templateVersionIds || group.effectivePlan?.templateVersionIds || [])
setPreview(null)
}, [group])
const changeGroup = (groupId: string) => {
if (groupId === selectedGroupId) return
setSelectedGroupId(groupId)
}
const openPublishPreview = async () => {
if (!group) return
if (!group.draftPlan) {
onError('当前没有待发布的岗位修改,请先到“岗位”页面调整考核项并保存')
return
}
if (templateVersionIds.length === 0) {
onError('岗位考核方案至少需要 1 个已发布模板')
return
}
setSaving(true)
onError(null)
try {
const serverPreview = await adminConfigApi.previewPositionGroup(request, group.id, month)
setPreview(serverPreview || {})
} catch (previewError) {
const warning = previewError instanceof Error ? previewError.message : '服务端影响预览暂不可用'
setPreview({
warning,
blockingIssues: [warning],
memberCount: group.memberCount,
unmatchedCount: data.issues.unmatched.length,
conflictCount: data.issues.conflicts.length,
})
} finally {
setSaving(false)
}
}
const publishPlan = async () => {
if (!group) return
const draftPlanId = String(preview?.draftPlanId || '').trim()
const draftVersion = Number(preview?.draftVersion)
const draftUpdatedAt = String(preview?.draftUpdatedAt || '').trim()
if (!draftPlanId || !Number.isInteger(draftVersion) || !draftUpdatedAt) {
onError('发布预览信息已失效,请重新执行发布前检查')
setPreview(null)
return
}
setSaving(true)
onError(null)
try {
await request(`/admin/position-groups/${group.id}/publish`, {
method: 'POST',
body: JSON.stringify({
effectiveMonth: month,
draftPlanId,
draftVersion,
draftUpdatedAt,
}),
})
setPreview(null)
await onChanged(`${group.name} 的岗位考核方案已发布,将从 ${month} 起持续生效`)
} catch (actionError) {
onError(actionError instanceof Error ? actionError.message : '岗位考核方案发布失败')
} finally {
setSaving(false)
}
}
if (!group) {
return <div className="py-12 text-center text-[13px] text-[#5f5e5e]">请先创建岗位</div>
}
const previewConflictCount = Number(preview?.conflictCount ?? preview?.conflicts?.length ?? data.issues.conflicts.length)
const previewMemberCount = Number(preview?.memberCount ?? preview?.affectedEmployees ?? group.memberCount)
const previewBlockingIssues = Array.isArray(preview?.blockingIssues)
? preview.blockingIssues.map((item: unknown) => String(item || '').trim()).filter(Boolean)
: []
return (
<section aria-labelledby="performance-survey-plan-title">
<div className="grid gap-3 border-b border-[#e2e2e2] pb-4 md:grid-cols-[minmax(240px,360px)_1fr] md:items-end">
<label className="grid gap-1 text-[12px] font-semibold text-[#5f5e5e]">
岗位
<select value={group.id} onChange={(event) => changeGroup(event.target.value)} className="h-11 rounded border border-[#c2c6d8] bg-white px-3 text-[13px] font-bold text-[#1a1c1c] outline-none focus:border-[#0050cb]">
{data.groups.map((item) => <option key={item.id} value={item.id}>{item.name}{item.status === 'disabled' ? '(已归档)' : ''}</option>)}
</select>
</label>
<div>
<h2 id="performance-survey-plan-title" className="text-[18px] font-bold text-[#1a1c1c]">岗位考核方案</h2>
<p className="mt-1 text-[12px] text-[#5f5e5e]">
查看月份:{month};当前使用:{group.effectivePlan
? `v${group.effectivePlan.version}(${group.effectivePlan.effectiveMonth} 起生效${group.effectivePlan.effectiveMonth < month ? `,${month} 继续沿用` : ''})`
: '尚未发布'}
{group.draftPlan ? `;草稿 v${group.draftPlan.version}` : ''}
</p>
</div>
</div>
<p className="mt-4 max-w-3xl text-[12px] leading-5 text-[#5f5e5e]">
已发布方案会从生效月份起持续沿用,后续月份无需重复发布。请先在“岗位”页面调整考核项并保存,再到这里检查影响并发布。
</p>
<div className="mt-5">
<h3 className="text-[14px] font-bold text-[#1a1c1c]">
{group.draftPlan ? '待发布的考核项' : '当前生效的考核项'}
</h3>
<div className="mt-2 overflow-x-auto border border-[#c2c6d8]">
<table className="w-full min-w-[620px] border-collapse text-left">
<thead>
<tr className="border-b border-[#c2c6d8] bg-[#f4f3f3] text-[12px] font-bold text-[#5f5e5e]">
<th scope="col" className="w-16 px-3 py-3">顺序</th>
<th scope="col" className="px-3 py-3">模板</th>
<th scope="col" className="w-32 px-3 py-3">版本</th>
</tr>
</thead>
<tbody className="divide-y divide-[#e2e2e2]">
{templateVersionIds.map((id, index) => {
const item = versionById.get(id)
return (
<tr key={id}>
<td className="px-3 py-3 text-[13px] font-bold text-[#1a1c1c]">{index + 1}</td>
<td className="px-3 py-3 text-[13px] font-bold text-[#1a1c1c]">{item?.version.definition.name || item?.template.name || '历史模板版本'}</td>
<td className="px-3 py-3 text-[12px] text-[#5f5e5e]">{item ? `v${item.version.version}` : id}</td>
</tr>
)
})}
{templateVersionIds.length === 0 && <tr><td colSpan={3} className="px-4 py-8 text-center text-[13px] text-[#8a5700]">尚未选择考核项,请先到“岗位”页面添加</td></tr>}
</tbody>
</table>
</div>
</div>
{preview && (
<div className="mt-5 border border-[#b3c5ff] bg-[#f8faff] px-4 py-4" role="region" aria-label="发布影响确认">
<h3 className="text-[14px] font-bold text-[#1a1c1c]">发布影响确认</h3>
<ul className="mt-2 space-y-1 text-[13px] leading-5 text-[#5f5e5e]">
<li>新方案生效月份:{month}</li>
<li>影响岗位:{group.name},约 {previewMemberCount} 人</li>
<li>模板数量:{templateVersionIds.length} 个</li>
<li className={previewConflictCount > 0 ? 'font-bold text-[#b42318]' : ''}>重复匹配:{previewConflictCount} 人</li>
{preview.warning && <li className="text-[#8a5700]">预览提示:{preview.warning}</li>}
{previewBlockingIssues.map((issue: string) => (
<li key={issue} className="font-bold text-[#b42318]">阻塞项:{issue}</li>
))}
</ul>
<div className="mt-3 flex flex-wrap justify-end gap-2">
<button type="button" onClick={() => setPreview(null)} className="min-h-11 rounded border border-[#c2c6d8] px-4 text-[13px] font-bold text-[#1a1c1c]">返回修改</button>
<button type="button" disabled={saving || previewConflictCount > 0 || previewBlockingIssues.length > 0} onClick={() => void publishPlan()} className="min-h-11 rounded bg-[#0050cb] px-4 text-[13px] font-bold text-white disabled:opacity-50">
{saving ? '发布中...' : `确认发布,${month} 起生效`}
</button>
</div>
</div>
)}
<div className="sticky bottom-[68px] z-20 -mx-4 mt-6 flex flex-wrap justify-end gap-2 border-t border-[#c2c6d8] bg-white/95 px-4 py-3 shadow-[0_-4px_10px_rgba(0,0,0,0.06)] backdrop-blur sm:bottom-0 sm:mx-0 sm:shadow-none">
<span className="mr-auto text-[12px] font-semibold text-[#5f5e5e]">
{group.draftPlan ? '当前有待发布修改' : '当前没有待发布修改'}
</span>
<button type="button" disabled={saving || group.status === 'disabled' || !group.draftPlan} onClick={() => void openPublishPreview()} className="min-h-11 rounded bg-[#0050cb] px-4 text-[13px] font-bold text-white disabled:opacity-50">发布前检查</button>
</div>
</section>
)
}
function QuestionEditor({
definition,
editable,
onChange,
}: {
definition: TemplateDefinition
editable: boolean
onChange: (definition: TemplateDefinition) => void
}) {
const perItemTotal = definition.questions.reduce(
(sum, question) => sum + Number(question.maxScore || definition.questionMaxScore || 20),
0,
)
const workItemCount = definition.surveyType === 'visual_case'
? Math.max(1, Number(definition.requiredWorkItemCount || 1))
: 1
const allocatedTotal = perItemTotal * workItemCount
const configuredTotal = Number(definition.maxTotal || 0)
const totalMatches = Math.round(allocatedTotal * 100) === Math.round(configuredTotal * 100)
return (
<section aria-labelledby="template-questions-heading">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 id="template-questions-heading" className="text-[15px] font-bold text-[#1a1c1c]">评分项</h3>
<p className="mt-1 text-[12px] leading-5 text-[#727687]">评分人将逐项打分;所有评分项满分之和必须等于总分。</p>
</div>
{editable && (
<button
type="button"
onClick={() => onChange({
...definition,
questions: [...definition.questions, {
id: `score_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
title: `评分项 ${definition.questions.length + 1}`,
description: '',
maxScore: definition.questionMaxScore || 20,
}],
})}
className="min-h-11 rounded border border-[#1a1c1c] px-4 text-[12px] font-bold text-[#1a1c1c] hover:bg-[#1a1c1c] hover:text-white"
>
添加评分项
</button>
)}
</div>
<div className={`mt-4 grid gap-3 border px-4 py-4 sm:grid-cols-[minmax(0,240px)_1fr] sm:items-end ${totalMatches ? 'border-[#c2c6d8] bg-[#fafafa]' : 'border-[#e7b64a] bg-[#fff9ec]'}`}>
<label className="grid min-w-0 gap-1 text-[12px] font-semibold text-[#5f5e5e]">
总分
<input
type="number"
min="1"
value={configuredTotal || ''}
disabled={!editable}
onChange={(event) => onChange({ ...definition, maxTotal: Math.max(1, Number(event.target.value) || 1) })}
className="h-11 min-w-0 w-full rounded border border-[#c2c6d8] px-3 text-[13px] font-bold text-[#1a1c1c] outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4]"
/>
</label>
<div className={`text-[12px] leading-5 ${totalMatches ? 'text-[#5f5e5e]' : 'font-bold text-[#8a5700]'}`}>
{definition.surveyType === 'visual_case'
? `已分配 ${perItemTotal} × ${workItemCount} 个作品 = ${allocatedTotal} / ${configuredTotal || 0} 分`
: `已分配 ${allocatedTotal} / ${configuredTotal || 0} 分`}
{!totalMatches && <span className="block">请调整评分项满分或总分,二者一致后才能发布。</span>}
</div>
</div>
<div className="mt-4 grid gap-3">
{definition.questions.length === 0 && (
<div className="border border-dashed border-[#c2c6d8] px-4 py-8 text-center text-[12px] text-[#727687]">
还没有评分项,请点击“添加评分项”
</div>
)}
{definition.questions.map((question, index) => (
<div key={`${question.id}-${index}`} className="border border-[#e2e2e2] p-4">
<div className="flex items-center justify-between gap-3 border-b border-[#eeeeee] pb-3">
<div className="text-[13px] font-bold text-[#1a1c1c]">评分项 {index + 1}</div>
{editable && (
<button
type="button"
aria-label={`删除评分项 ${index + 1}`}
onClick={() => onChange({ ...definition, questions: definition.questions.filter((_, itemIndex) => itemIndex !== index) })}
className="min-h-11 rounded px-3 text-[12px] font-bold text-[#b42318] hover:bg-[#fff5f5]"
>
删除
</button>
)}
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-[minmax(0,1fr)_120px]">
<label className="grid min-w-0 gap-1 text-[12px] font-semibold text-[#5f5e5e]">
评分项名称
<input
value={question.title}
disabled={!editable}
onChange={(event) => onChange({ ...definition, questions: definition.questions.map((item, itemIndex) => itemIndex === index ? { ...item, title: event.target.value } : item) })}
className="h-11 min-w-0 w-full rounded border border-[#e2e2e2] px-3 text-[13px] outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4]"
/>
</label>
<label className="grid min-w-0 gap-1 text-[12px] font-semibold text-[#5f5e5e]">
满分
<input
type="number"
min="1"
value={question.maxScore || 20}
disabled={!editable}
onChange={(event) => onChange({ ...definition, questions: definition.questions.map((item, itemIndex) => itemIndex === index ? { ...item, maxScore: Number(event.target.value) || 1 } : item) })}
className="h-11 min-w-0 w-full rounded border border-[#e2e2e2] px-3 text-[13px] outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4]"
/>
</label>
<label className="grid gap-1 text-[12px] font-semibold text-[#5f5e5e] sm:col-span-2">
评分说明(可选)
<textarea
value={question.description || ''}
disabled={!editable}
placeholder="告诉评分人这一项应该看什么"
onChange={(event) => onChange({ ...definition, questions: definition.questions.map((item, itemIndex) => itemIndex === index ? { ...item, description: event.target.value } : item) })}
className="min-h-20 rounded border border-[#e2e2e2] px-3 py-2 text-[13px] outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4]"
/>
</label>
</div>
</div>
))}
</div>
</section>
)
}
function WorkflowEditor({
definition,
editable,
request,
onChange,
}: {
definition: TemplateDefinition
editable: boolean
request: RequestFn
onChange: (definition: TemplateDefinition) => void
}) {
const updateStages = (nextStages: StageDraft[]) => {
const stages = nextStages.map((stage, index) => ({ ...stage, order: index }))
const currentScoring = definition.workflow.scoring
const scoring = currentScoring.mode === 'average_reviewers'
? { mode: 'average_reviewers' as const, stageKeys: stages.map((stage) => stage.key) }
: (() => {
const selectedStage = stages.find((stage) => stage.key === currentScoring.stageKey) || stages[stages.length - 1]
const selectedReviewer = selectedStage?.reviewers.find((reviewer) => reviewer.source === currentScoring.reviewerSource)
|| selectedStage?.reviewers[0]
return {
mode: 'final_reviewer' as const,
stageKey: selectedStage?.key || '',
reviewerSource: selectedReviewer?.source || 'manual_selection' as ReviewerSource,
}
})()
onChange({
...definition,
workflow: { ...definition.workflow, stages, scoring },
})
}
const stages = definition.workflow.stages
return (
<section aria-labelledby="template-workflow-heading">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 id="template-workflow-heading" className="text-[15px] font-bold text-[#1a1c1c]">评审与计分</h3>
<p className="mt-1 text-[12px] text-[#727687]">同阶段并行,上一阶段全部提交后开放下一阶段</p>
</div>
{editable && <button type="button" onClick={() => updateStages([...stages, { key: `stage_${stages.length + 1}`, label: `阶段 ${stages.length + 1}`, order: stages.length, reviewers: [{ source: 'manual_selection', label: '评价人' }] }])} className="min-h-11 rounded border border-[#1a1c1c] px-4 text-[12px] font-bold text-[#1a1c1c] hover:bg-[#1a1c1c] hover:text-white">添加阶段</button>}
</div>
<div className="mt-3 grid gap-3">
{stages.map((stage, stageIndex) => (
<div key={`${stage.key}-${stageIndex}`} className="border border-[#e2e2e2] p-4">
<div className="flex items-end gap-3">
<label className="grid min-w-0 flex-1 gap-1 text-[12px] font-semibold text-[#5f5e5e]">
阶段 {stageIndex + 1} 名称
<input
value={stage.label}
disabled={!editable}
onChange={(event) => updateStages(stages.map((item, index) => index === stageIndex ? { ...item, label: event.target.value } : item))}
className="h-11 rounded border border-[#e2e2e2] px-3 text-[13px] outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4]"
/>
</label>
{editable && (
<button
type="button"
disabled={stages.length <= 1}
onClick={() => updateStages(stages.filter((_, index) => index !== stageIndex))}
className="min-h-11 rounded px-3 text-[12px] font-bold text-[#b42318] hover:bg-[#fff5f5] disabled:cursor-not-allowed disabled:text-[#aaaaaa]"
>
删除阶段
</button>
)}
</div>
<p className="mt-3 text-[11px] font-bold text-[#727687]">本阶段评分人</p>
<div className="mt-3 grid gap-2">
{stage.reviewers.map((reviewer, reviewerIndex) => (
<div key={`${stage.key}-${reviewerIndex}`} className="grid gap-2 sm:grid-cols-[180px_minmax(180px,1fr)_72px]">
<select
aria-label={`阶段 ${stageIndex + 1} 评分人类型 ${reviewerIndex + 1}`}
value={reviewer.source}
disabled={!editable}
onChange={(event) => updateStages(stages.map((item, index) => index === stageIndex ? {
...item,
reviewers: item.reviewers.map((entry, entryIndex) => entryIndex === reviewerIndex ? {
source: event.target.value as ReviewerSource,
label: reviewerSourceLabel(event.target.value as ReviewerSource),
} : entry),
} : item))}
className="h-11 rounded border border-[#e2e2e2] bg-white px-3 text-[12px] font-semibold outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4]"
>
<option value="self">本人</option>
<option value="direct_manager">直属主管</option>
<option value="fixed_employee">固定员工</option>
<option value="manual_selection">发起时选择</option>
</select>
{reviewer.source === 'fixed_employee' ? (
<FixedReviewerPicker
value={reviewer}
disabled={!editable}
request={request}
onChange={(value) => updateStages(stages.map((item, index) => index === stageIndex ? {
...item,
reviewers: item.reviewers.map((entry, entryIndex) => entryIndex === reviewerIndex ? value : entry),
} : item))}
/>
) : (
<input
aria-label={`阶段 ${stageIndex + 1} 评分人显示名称 ${reviewerIndex + 1}`}
value={reviewer.label}
disabled={!editable}
onChange={(event) => updateStages(stages.map((item, index) => index === stageIndex ? { ...item, reviewers: item.reviewers.map((entry, entryIndex) => entryIndex === reviewerIndex ? { ...entry, label: event.target.value } : entry) } : item))}
className="h-11 rounded border border-[#e2e2e2] px-3 text-[12px] outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4]"
/>
)}
{editable && (
<button
type="button"
disabled={stage.reviewers.length <= 1}
aria-label={`删除阶段 ${stageIndex + 1} 的第 ${reviewerIndex + 1} 类评分人`}
onClick={() => updateStages(stages.map((item, index) => index === stageIndex ? { ...item, reviewers: item.reviewers.filter((_, entryIndex) => entryIndex !== reviewerIndex) } : item))}
className="min-h-11 rounded text-[12px] font-bold text-[#b42318] hover:bg-[#fff5f5] disabled:cursor-not-allowed disabled:text-[#aaaaaa]"
>
删除
</button>
)}
</div>
))}
{editable && <button type="button" onClick={() => updateStages(stages.map((item, index) => index === stageIndex ? { ...item, reviewers: [...item.reviewers, { source: 'manual_selection', label: '评价人' }] } : item))} className="min-h-11 w-fit rounded border border-[#e2e2e2] px-3 text-[11px] font-bold text-[#1a1c1c] hover:border-[#1a1c1c]">添加一类评分人</button>}
</div>
<details className="mt-3">
<summary className="min-h-11 cursor-pointer py-3 text-[11px] font-bold text-[#727687]">高级设置</summary>
<label className="grid max-w-sm gap-1 text-[11px] font-semibold text-[#727687]">
系统内部阶段代码
<input
value={stage.key}
disabled={!editable}
onChange={(event) => updateStages(stages.map((item, index) => index === stageIndex ? { ...item, key: event.target.value } : item))}
className="h-11 rounded border border-[#e2e2e2] px-3 text-[12px] outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4]"
/>
</label>
</details>
</div>
))}
</div>
<div className="mt-5 border-t border-[#eeeeee] pt-5">
<h4 className="text-[14px] font-bold text-[#1a1c1c]">最终成绩采用哪一组评分</h4>
<p className="mt-1 text-[12px] leading-5 text-[#727687]">这里只决定最终展示的分数;各评分人的原始分仍会保留。</p>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
<label className="grid gap-1 text-[12px] font-semibold text-[#5f5e5e]">
最终成绩规则
<select
value={definition.workflow.scoring.mode}
disabled={!editable}
onChange={(event) => onChange({
...definition,
workflow: {
...definition.workflow,
scoring: event.target.value === 'final_reviewer'
? { mode: 'final_reviewer', stageKey: stages[stages.length - 1]?.key || '', reviewerSource: stages[stages.length - 1]?.reviewers[0]?.source || 'manual_selection' }
: { mode: 'average_reviewers', stageKeys: stages.map((stage) => stage.key) },
},
})}
className="h-11 rounded border border-[#e2e2e2] bg-white px-3 text-[12px] font-semibold disabled:bg-[#f4f4f4]"
>
<option value="average_reviewers">所有指定阶段评分取平均</option>
<option value="final_reviewer">以指定终审人评分为准</option>
</select>
</label>
{definition.workflow.scoring.mode === 'final_reviewer' && (
<>
<label className="grid gap-1 text-[12px] font-semibold text-[#5f5e5e]">
终审阶段
<select value={definition.workflow.scoring.stageKey || ''} disabled={!editable} onChange={(event) => onChange({ ...definition, workflow: { ...definition.workflow, scoring: { ...definition.workflow.scoring, mode: 'final_reviewer', stageKey: event.target.value } } })} className="h-11 rounded border border-[#e2e2e2] bg-white px-3 text-[12px] font-semibold disabled:bg-[#f4f4f4]">
{stages.map((stage) => <option key={stage.key} value={stage.key}>{stage.label}</option>)}
</select>
</label>
<label className="grid gap-1 text-[12px] font-semibold text-[#5f5e5e]">
终审人来源
<select value={definition.workflow.scoring.reviewerSource || ''} disabled={!editable} onChange={(event) => onChange({ ...definition, workflow: { ...definition.workflow, scoring: { ...definition.workflow.scoring, mode: 'final_reviewer', reviewerSource: event.target.value as ReviewerSource } } })} className="h-11 rounded border border-[#e2e2e2] bg-white px-3 text-[12px] font-semibold disabled:bg-[#f4f4f4]">
{(stages.find((stage) => stage.key === definition.workflow.scoring.stageKey)?.reviewers || []).map((reviewer) => <option key={reviewer.source} value={reviewer.source}>{reviewer.employeeName || reviewer.label}</option>)}
</select>
</label>
</>
)}
</div>
</section>
)
}
function FixedReviewerPicker({
value,
disabled,
request,
onChange,
}: {
value: ReviewerDraft
disabled: boolean
request: RequestFn
onChange: (value: ReviewerDraft) => void
}) {
const [search, setSearch] = useState('')
const [items, setItems] = useState<Array<{ id: string; displayName: string; departmentName?: string }>>([])
useEffect(() => {
if (disabled || search.trim().length < 1) {
setItems([])
return
}
const timer = window.setTimeout(async () => {
try {
setItems(await request(`/employees?purpose=evaluator&limit=20&search=${encodeURIComponent(search.trim())}`))
} catch {
setItems([])
}
}, 250)
return () => window.clearTimeout(timer)
}, [disabled, request, search])
return (
<div className="relative min-w-0">
<input
aria-label="搜索固定评分人"
value={search || value.employeeName || ''}
disabled={disabled}
onChange={(event) => {
setSearch(event.target.value)
onChange({ source: 'fixed_employee', label: '固定员工' })
}}
placeholder="搜索固定评分人"
className="h-11 w-full rounded border border-[#e2e2e2] px-3 text-[12px] outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4]"
/>
{items.length > 0 && (
<div className="absolute inset-x-0 top-12 z-30 max-h-48 overflow-y-auto border border-[#e2e2e2] bg-white shadow-[0_10px_24px_rgba(0,0,0,0.1)]">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
onChange({ source: 'fixed_employee', label: '固定员工', employeeId: item.id, employeeName: item.displayName })
setSearch('')
setItems([])
}}
className="block min-h-11 w-full border-b border-[#eeeeee] px-3 py-2 text-left text-[12px] hover:bg-[#f7f7f7]"
>
<strong>{item.displayName}</strong>{item.departmentName ? ` · ${item.departmentName}` : ''}
</button>
))}
</div>
)}
</div>
)
}
function AdminField({
label,
value,
onChange,
type = 'text',
disabled = false,
}: {
label: string
value: string
onChange: (value: string) => void
type?: string
disabled?: boolean
}) {
return (
<label className="grid min-w-0 gap-1 text-[12px] font-semibold text-[#5f5e5e]">
{label}
<input
type={type}
value={value}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
className="h-11 min-w-0 w-full rounded border border-[#e2e2e2] px-3 text-[13px] font-semibold text-[#1a1c1c] outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4] disabled:text-[#8a8a8a]"
/>
</label>
)
}