1901 lines
83 KiB
TypeScript
1901 lines
83 KiB
TypeScript
'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>
|
||
)
|
||
}
|