feat: 优化考核调研管理配置体验
This commit is contained in:
parent
b07088affe
commit
91ad3941e7
@ -1,6 +1,6 @@
|
||||
/** @jest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { PerformanceSurveyAdminConfig } from './PerformanceSurveyAdminConfig'
|
||||
|
||||
const templateDefinition = {
|
||||
@ -123,6 +123,9 @@ function createRequest(configuration = config) {
|
||||
pageSize: 200,
|
||||
}
|
||||
}
|
||||
if (path.startsWith('/employees?purpose=evaluator')) {
|
||||
return [{ id: 'employee-yuanze', displayName: '元泽', departmentName: '品牌中心' }]
|
||||
}
|
||||
if (path.endsWith('/preview?month=2026-07')) {
|
||||
return {
|
||||
memberCount: 6,
|
||||
@ -156,7 +159,7 @@ describe('PerformanceSurveyAdminConfig', () => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
test('概览展示岗位、人员、模板和岗位考核方案状态', async () => {
|
||||
test('概览将岗位设置与发布状态合并展示', async () => {
|
||||
const request = createRequest()
|
||||
const onNavigate = jest.fn()
|
||||
render(<PerformanceSurveyAdminConfig mode="overview" request={request} onNavigate={onNavigate} />)
|
||||
@ -183,7 +186,7 @@ describe('PerformanceSurveyAdminConfig', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: '2026-08 配置概览' })).toBeInTheDocument())
|
||||
expect(screen.getByText(/1\/2 个在用岗位已有生效方案,.*1 个沿用较早版本/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/1\/2 个岗位设置已生效,.*1 个沿用较早设置/)).toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText('查看月份'), { target: { value: '2026-09' } })
|
||||
await waitFor(() => expect(onMonthChange).toHaveBeenCalledWith('2026-09'))
|
||||
})
|
||||
@ -256,7 +259,7 @@ describe('PerformanceSurveyAdminConfig', () => {
|
||||
fireEvent.change(screen.getByLabelText('添加考核项'), { target: { value: '视觉案例' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /视觉案例评分/ }))
|
||||
expect(screen.getByText('视觉案例评分')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }))
|
||||
|
||||
await waitFor(() => {
|
||||
const call = request.mock.calls.find(([path, options]) => (
|
||||
@ -330,6 +333,52 @@ describe('PerformanceSurveyAdminConfig', () => {
|
||||
expect(screen.queryByText('使用场景')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('模板列表明确区分当前可用、未来可用和仅草稿状态', async () => {
|
||||
const configuration = {
|
||||
...config,
|
||||
templates: [{
|
||||
...config.templates[0],
|
||||
versions: [{
|
||||
...config.templates[0].versions[0],
|
||||
id: 'version-draft',
|
||||
version: 2,
|
||||
status: 'draft',
|
||||
effectiveMonth: null,
|
||||
}, config.templates[0].versions[0]],
|
||||
}, {
|
||||
...config.templates[1],
|
||||
versions: [{
|
||||
...config.templates[1].versions[0],
|
||||
id: 'version-future',
|
||||
effectiveMonth: '2026-09',
|
||||
}],
|
||||
}, {
|
||||
...config.templates[1],
|
||||
id: 'template-draft-only',
|
||||
code: 'draft_only',
|
||||
name: '草稿模板',
|
||||
versions: [{
|
||||
...config.templates[1].versions[0],
|
||||
id: 'version-draft-only',
|
||||
status: 'draft',
|
||||
effectiveMonth: null,
|
||||
definition: {
|
||||
...config.templates[1].versions[0].definition,
|
||||
code: 'draft_only',
|
||||
name: '草稿模板',
|
||||
},
|
||||
}],
|
||||
}],
|
||||
}
|
||||
const request = createRequest(configuration)
|
||||
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /视觉案例评分.*可用/ })).toBeInTheDocument())
|
||||
expect(screen.getByRole('button', { name: /视觉案例评分.*有未发布修改/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /课程分享.*不可用.*2026-09 起可用/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /草稿模板.*不可用.*仅有草稿/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('删除模板使用统一弹窗并保留接口错误以便重试', async () => {
|
||||
const baseRequest = createRequest()
|
||||
let deleteAttempts = 0
|
||||
@ -384,7 +433,7 @@ describe('PerformanceSurveyAdminConfig', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /发布至 2026-07/ }))
|
||||
const dialog = await screen.findByRole('dialog', { name: '发布模板“视觉案例评分”?' })
|
||||
expect(dialog).toHaveTextContent('模板将从 2026-07 起供岗位考核方案使用')
|
||||
expect(dialog).toHaveTextContent('模板将从 2026-07 起成为可用模板')
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '发布至 2026-07' }))
|
||||
|
||||
await waitFor(() => expect(request).toHaveBeenCalledWith(
|
||||
@ -396,22 +445,87 @@ describe('PerformanceSurveyAdminConfig', () => {
|
||||
))
|
||||
})
|
||||
|
||||
test('模板编辑按基本信息、评分项、评审与计分分步展示', async () => {
|
||||
test('模板编辑按基本信息、评分项、评分人设置分步展示', async () => {
|
||||
const request = createRequest()
|
||||
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
|
||||
await waitFor(() => expect(screen.getByRole('navigation', { name: '模板编辑步骤' })).toBeInTheDocument())
|
||||
|
||||
expect(screen.getByRole('heading', { name: '基本信息' })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('考核内容类型')).toHaveValue('visual_case')
|
||||
expect(screen.getByText(/决定调研按员工表现、作品案例或一次具体事项来评分/)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '2. 评分项' }))
|
||||
expect(screen.getByRole('heading', { name: '评分项' })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('评分项名称')).toHaveValue('作品质量')
|
||||
expect(screen.getByLabelText('总分')).toHaveValue(60)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '3. 评审与计分' }))
|
||||
expect(screen.getByRole('heading', { name: '评审与计分' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '3. 评分人设置' }))
|
||||
expect(screen.getByRole('heading', { name: '评分人设置' })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('最终成绩规则')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('评分人设置提供直属主管、元泽和发起时选择任何人', async () => {
|
||||
const configuration = {
|
||||
...config,
|
||||
templates: config.templates.map((template: any) => template.id === 'template-1'
|
||||
? {
|
||||
...template,
|
||||
versions: template.versions.map((version: any) => ({ ...version, status: 'draft', effectiveMonth: null })),
|
||||
}
|
||||
: template),
|
||||
}
|
||||
const request = createRequest(configuration)
|
||||
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '3. 评分人设置' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '3. 评分人设置' }))
|
||||
const reviewerSelect = screen.getByLabelText('阶段 1 评分人类型 1')
|
||||
expect(reviewerSelect).toHaveValue('direct_manager')
|
||||
expect(within(reviewerSelect).getByRole('option', { name: '直属主管' })).toBeInTheDocument()
|
||||
expect(within(reviewerSelect).getByRole('option', { name: '元泽' })).toBeInTheDocument()
|
||||
expect(within(reviewerSelect).getByRole('option', { name: '任何人(发起时选择)' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(reviewerSelect, { target: { value: 'fixed_employee' } })
|
||||
await waitFor(() => expect(screen.getByLabelText('选择元泽人员档案')).toHaveValue('元泽'))
|
||||
|
||||
fireEvent.change(reviewerSelect, { target: { value: 'manual_selection' } })
|
||||
await waitFor(() => expect(screen.getByText(/终评:发起时选择任何人/)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
test('元泽人员档案异步返回时不会覆盖等待期间的流程修改', async () => {
|
||||
const configuration = {
|
||||
...config,
|
||||
templates: config.templates.map((template: any) => template.id === 'template-1'
|
||||
? {
|
||||
...template,
|
||||
versions: template.versions.map((version: any) => ({ ...version, status: 'draft', effectiveMonth: null })),
|
||||
}
|
||||
: template),
|
||||
}
|
||||
let resolveEmployees: ((employees: Array<{ id: string; displayName: string }>) => void) | null = null
|
||||
const employeeRequest = new Promise<Array<{ id: string; displayName: string }>>((resolve) => {
|
||||
resolveEmployees = resolve
|
||||
})
|
||||
const baseRequest = createRequest(configuration)
|
||||
const request = jest.fn(async (path: string, options?: RequestInit) => {
|
||||
if (path.startsWith('/employees?purpose=evaluator')) return employeeRequest
|
||||
return baseRequest(path, options)
|
||||
})
|
||||
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '3. 评分人设置' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '3. 评分人设置' }))
|
||||
fireEvent.change(screen.getByLabelText('阶段 1 评分人类型 1'), { target: { value: 'fixed_employee' } })
|
||||
fireEvent.change(screen.getByLabelText('阶段 1 名称'), { target: { value: '终审修改' } })
|
||||
|
||||
await act(async () => {
|
||||
resolveEmployees?.([{ id: 'employee-yuanze', displayName: '元泽' }])
|
||||
await employeeRequest
|
||||
})
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText('选择元泽人员档案')).toHaveValue('元泽'))
|
||||
expect(screen.getByLabelText('阶段 1 名称')).toHaveValue('终审修改')
|
||||
})
|
||||
|
||||
test('模板草稿可自由增删评分项并显示总分分配状态', async () => {
|
||||
const configuration = {
|
||||
...config,
|
||||
@ -434,7 +548,7 @@ describe('PerformanceSurveyAdminConfig', () => {
|
||||
expect(screen.getByText('已分配 20 × 3 个作品 = 60 / 60 分')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('岗位考核方案只复核岗位页草稿,并经过影响确认后发布', async () => {
|
||||
test('岗位页保存草稿后可直接经过影响确认并发布', async () => {
|
||||
const configuration = {
|
||||
...config,
|
||||
groups: config.groups.map((group: any) => group.id === 'group-1'
|
||||
@ -450,15 +564,14 @@ describe('PerformanceSurveyAdminConfig', () => {
|
||||
: group),
|
||||
}
|
||||
const request = createRequest(configuration)
|
||||
render(<PerformanceSurveyAdminConfig mode="plans" request={request} />)
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: '岗位考核方案' })).toBeInTheDocument())
|
||||
render(<PerformanceSurveyAdminConfig mode="positions" request={request} />)
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: '岗位考核项' })).toBeInTheDocument())
|
||||
expect(screen.getByText('草稿已保存,等待发布')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '发布岗位设置' }))
|
||||
|
||||
expect(screen.getByRole('heading', { name: '待发布的考核项' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: '可用已发布模板' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '发布前检查' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('region', { name: '发布影响确认' })).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认发布,2026-07 起生效' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: '发布“品牌设计师”的岗位设置?' })
|
||||
expect(dialog).toHaveTextContent('包含 2 个考核项,预计影响 6 人')
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认发布' }))
|
||||
|
||||
await waitFor(() => expect(request).toHaveBeenCalledWith('/admin/position-groups/group-1/publish', expect.objectContaining({
|
||||
method: 'POST',
|
||||
|
||||
@ -14,7 +14,7 @@ import {
|
||||
type RequestFn = PerformanceSurveyAdminRequest
|
||||
type ConfirmActionFn = (options: ConfirmActionOptions) => Promise<ConfirmActionResult>
|
||||
|
||||
export type PerformanceSurveyAdminMode = 'overview' | 'positions' | 'people' | 'templates' | 'plans'
|
||||
export type PerformanceSurveyAdminMode = 'overview' | 'positions' | 'people' | 'templates'
|
||||
|
||||
type ReviewerSource = 'self' | 'direct_manager' | 'fixed_employee' | 'manual_selection'
|
||||
|
||||
@ -181,7 +181,7 @@ function newTemplateDefinition(): TemplateDefinition {
|
||||
key: 'review',
|
||||
label: '评价',
|
||||
order: 0,
|
||||
reviewers: [{ source: 'manual_selection', label: '评价人' }],
|
||||
reviewers: [{ source: 'direct_manager', label: '直属主管' }],
|
||||
}],
|
||||
scoring: { mode: 'average_reviewers', stageKeys: ['review'] },
|
||||
},
|
||||
@ -190,21 +190,46 @@ function newTemplateDefinition(): TemplateDefinition {
|
||||
|
||||
function reviewerSourceLabel(source: ReviewerSource) {
|
||||
return {
|
||||
self: '本人',
|
||||
self: '本人自评',
|
||||
direct_manager: '直属主管',
|
||||
fixed_employee: '固定员工',
|
||||
manual_selection: '发起时选择',
|
||||
fixed_employee: '元泽',
|
||||
manual_selection: '发起时选择任何人',
|
||||
}[source]
|
||||
}
|
||||
|
||||
function surveyTypeLabel(type: TemplateDefinition['surveyType']) {
|
||||
return {
|
||||
person_period: '按人员评分',
|
||||
visual_case: '按作品评分',
|
||||
item_course: '按课程或事项评分',
|
||||
person_period: '员工工作表现',
|
||||
visual_case: '作品或案例',
|
||||
item_course: '课程或事项',
|
||||
}[type]
|
||||
}
|
||||
|
||||
function getTemplateAvailability(template: TemplateRecord, month: string) {
|
||||
const publishedVersions = template.versions.filter((version) => version.status === 'published')
|
||||
const availableVersion = publishedVersions
|
||||
.filter((version) => Boolean(version.effectiveMonth) && String(version.effectiveMonth) <= month)
|
||||
.sort((left, right) => (
|
||||
String(right.effectiveMonth).localeCompare(String(left.effectiveMonth))
|
||||
|| right.version - left.version
|
||||
))[0] || null
|
||||
const futureVersion = publishedVersions
|
||||
.filter((version) => Boolean(version.effectiveMonth) && String(version.effectiveMonth) > month)
|
||||
.sort((left, right) => (
|
||||
String(left.effectiveMonth).localeCompare(String(right.effectiveMonth))
|
||||
|| right.version - left.version
|
||||
))[0] || null
|
||||
const draftVersion = template.versions.find((version) => version.status === 'draft') || null
|
||||
const hasPublishedVersionWithoutMonth = publishedVersions.some((version) => !version.effectiveMonth)
|
||||
return {
|
||||
isAvailable: template.isActive && Boolean(availableVersion),
|
||||
availableVersion,
|
||||
futureVersion,
|
||||
draftVersion,
|
||||
hasPublishedVersionWithoutMonth,
|
||||
}
|
||||
}
|
||||
|
||||
export function PerformanceSurveyAdminConfig({
|
||||
mode,
|
||||
request,
|
||||
@ -271,6 +296,12 @@ export function PerformanceSurveyAdminConfig({
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [dirty])
|
||||
|
||||
useEffect(() => {
|
||||
if (!notice) return
|
||||
const timer = window.setTimeout(() => setNotice(null), 4500)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [notice])
|
||||
|
||||
const confirmDiscard = useCallback(async (message = '当前有未保存修改,确定放弃吗?') => {
|
||||
if (!dirty) return true
|
||||
const result = await confirmAction({
|
||||
@ -308,7 +339,6 @@ export function PerformanceSurveyAdminConfig({
|
||||
positions: '岗位管理',
|
||||
people: '人员分配',
|
||||
templates: '模板管理',
|
||||
plans: '岗位考核方案',
|
||||
}[mode]}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-[12px] text-[#5f5e5e]">
|
||||
@ -317,9 +347,7 @@ export function PerformanceSurveyAdminConfig({
|
||||
: mode === 'templates'
|
||||
? '已发布模板会长期使用;只有评分规则变化时才创建新版本'
|
||||
: mode === 'positions'
|
||||
? '岗位名称、排序和考核项修改会先保存为草稿,发布后生效'
|
||||
: mode === 'plans'
|
||||
? `正在查看 ${month} 的生效方案;已发布方案会持续沿用`
|
||||
? '岗位名称、排序和考核项在当前页面保存并发布,发布后持续生效'
|
||||
: `正在维护 ${month};已发布版本保持不变`}
|
||||
</p>
|
||||
</div>
|
||||
@ -334,14 +362,9 @@ export function PerformanceSurveyAdminConfig({
|
||||
</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>
|
||||
</>
|
||||
<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>
|
||||
)}
|
||||
{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]">
|
||||
@ -398,15 +421,6 @@ export function PerformanceSurveyAdminConfig({
|
||||
dirty={dirty}
|
||||
/>
|
||||
)}
|
||||
{mode === 'plans' && (
|
||||
<MonthlyPlanWorkspace
|
||||
data={data}
|
||||
month={month}
|
||||
request={request}
|
||||
onError={setError}
|
||||
onChanged={changed}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{confirmActionDialog}
|
||||
@ -432,22 +446,18 @@ function AdminOverviewWorkspace({
|
||||
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 availablePublishedTemplates = data.templates.filter((template) => (
|
||||
getTemplateAvailability(template, month).isAvailable
|
||||
)).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 positionsReady = activeGroups.length > 0 && effectivePlanCount === activeGroups.length
|
||||
const templatesReady = availablePublishedTemplates > 0
|
||||
const plansReady = activeGroups.length > 0 && effectivePlanCount === activeGroups.length
|
||||
const launchReady = positionsReady && templatesReady && plansReady && data.issues.conflicts.length === 0
|
||||
const launchReady = positionsReady && templatesReady && data.issues.conflicts.length === 0
|
||||
const steps = [
|
||||
{
|
||||
id: 'people',
|
||||
@ -463,12 +473,12 @@ function AdminOverviewWorkspace({
|
||||
},
|
||||
{
|
||||
id: 'positions',
|
||||
label: '确认岗位',
|
||||
status: positionsReady ? 'done' : 'attention',
|
||||
label: '配置并发布岗位',
|
||||
status: positionsReady ? (draftPlanCount > 0 ? 'attention' : 'done') : 'blocked',
|
||||
summary: activeGroups.length === 0
|
||||
? '还没有可用岗位'
|
||||
: `${activeGroups.length} 个岗位可用;人员归属请在“人员分配”中维护${disabledGroups > 0 ? `,另有 ${disabledGroups} 个已删除` : ''}`,
|
||||
action: '管理岗位',
|
||||
? '还没有岗位,请先创建岗位并选择考核项'
|
||||
: `${effectivePlanCount}/${activeGroups.length} 个岗位设置已生效${inheritedPlanCount > 0 ? `,其中 ${inheritedPlanCount} 个沿用较早设置` : ''}${draftPlanCount > 0 ? `;${draftPlanCount} 个岗位有待发布修改` : ''}${disabledGroups > 0 ? `,另有 ${disabledGroups} 个已删除` : ''}`,
|
||||
action: '配置岗位',
|
||||
target: 'positions' as const,
|
||||
},
|
||||
{
|
||||
@ -476,19 +486,11 @@ function AdminOverviewWorkspace({
|
||||
label: '准备考核模板',
|
||||
status: templatesReady ? (draftTemplates > 0 ? 'attention' : 'done') : 'blocked',
|
||||
summary: templatesReady
|
||||
? `${availablePublishedTemplates} 个版本可用于 ${month}${draftTemplates > 0 ? `,${draftTemplates} 个草稿待确认` : ''}`
|
||||
: `还没有可用于 ${month} 的已发布模板,岗位考核方案无法完成${publishedTemplates > 0 ? `(另有 ${publishedTemplates} 个其它月份版本)` : ''}`,
|
||||
? `${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: '发起调研',
|
||||
@ -524,7 +526,7 @@ function AdminOverviewWorkspace({
|
||||
{month} 配置概览
|
||||
</h2>
|
||||
<p className="mt-1 max-w-3xl text-[13px] leading-5 text-[#5f5e5e]">
|
||||
先处理人员匹配问题,再检查模板与岗位考核方案。已发布方案会持续沿用,无需每月重复配置。
|
||||
先处理人员归属,再准备模板并配置岗位考核项。岗位设置发布后会持续沿用,无需每月重复配置。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -670,17 +672,17 @@ function PositionConfigWorkspace({
|
||||
|
||||
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 availableTemplateVersions = useMemo(() => data.templates.flatMap((template) => {
|
||||
const availability = getTemplateAvailability(template, month)
|
||||
return availability.availableVersion
|
||||
? [{ template, version: availability.availableVersion }]
|
||||
: []
|
||||
}), [data.templates, month])
|
||||
const versionById = useMemo(
|
||||
() => new Map(publishedVersions.map((item) => [item.version.id, item])),
|
||||
[publishedVersions],
|
||||
() => new Map(data.templates.flatMap((template) => (
|
||||
template.versions.map((version) => [version.id, { template, version }] as const)
|
||||
))),
|
||||
[data.templates],
|
||||
)
|
||||
const searchResults = useMemo(() => {
|
||||
const keyword = templateSearch.trim().toLocaleLowerCase('zh-CN')
|
||||
@ -690,12 +692,12 @@ function PositionConfigWorkspace({
|
||||
.map((id) => versionById.get(id)?.template.id)
|
||||
.filter(Boolean),
|
||||
)
|
||||
return publishedVersions.filter(({ template, version }) => (
|
||||
return availableTemplateVersions.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])
|
||||
}, [availableTemplateVersions, templateSearch, templateVersionIds, versionById])
|
||||
|
||||
if (!selected) return <div className="px-5 py-12 text-center text-sm text-[#727687]">暂无岗位组</div>
|
||||
|
||||
@ -707,7 +709,7 @@ function PositionConfigWorkspace({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ name, sortOrder, templateVersionIds, effectiveMonth: month }),
|
||||
})
|
||||
await onChanged('岗位和考核项修改已保存,发布后生效')
|
||||
await onChanged('岗位设置草稿已保存,可直接在当前页面发布')
|
||||
} catch (actionError) {
|
||||
onError(actionError instanceof Error ? actionError.message : '岗位方案保存失败')
|
||||
} finally {
|
||||
@ -715,6 +717,75 @@ function PositionConfigWorkspace({
|
||||
}
|
||||
}
|
||||
|
||||
const publishPlan = async () => {
|
||||
if (dirty) {
|
||||
onError('请先保存当前修改,再发布岗位设置')
|
||||
return
|
||||
}
|
||||
if (!selected.draftPlan) {
|
||||
onError('当前没有待发布修改')
|
||||
return
|
||||
}
|
||||
if (templateVersionIds.length === 0) {
|
||||
onError('岗位至少需要选择 1 个可用考核模板')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
onError(null)
|
||||
try {
|
||||
const preview = await adminConfigApi.previewPositionGroup(request, selected.id, month)
|
||||
const conflictCount = Number(preview?.conflictCount ?? preview?.conflicts?.length ?? data.issues.conflicts.length)
|
||||
const blockingIssues = Array.isArray(preview?.blockingIssues)
|
||||
? preview.blockingIssues.map((item: unknown) => String(item || '').trim()).filter(Boolean)
|
||||
: []
|
||||
if (conflictCount > 0 || blockingIssues.length > 0) {
|
||||
const details = [
|
||||
conflictCount > 0 ? `${conflictCount} 人同时匹配多个岗位` : '',
|
||||
...blockingIssues,
|
||||
].filter(Boolean)
|
||||
onError(`发布前检查未通过:${details.join(';')}`)
|
||||
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('发布前检查信息不完整,请刷新页面后重试')
|
||||
return
|
||||
}
|
||||
|
||||
const memberCount = Number(preview?.memberCount ?? preview?.affectedEmployees ?? selected.memberCount)
|
||||
await confirmAction({
|
||||
title: `发布“${selected.name}”的岗位设置?`,
|
||||
description: (
|
||||
<div className="grid gap-1">
|
||||
<p>包含 {templateVersionIds.length} 个考核项,预计影响 {memberCount} 人。</p>
|
||||
<p>发布后从 {month} 起持续生效,后续月份无需重复设置。</p>
|
||||
</div>
|
||||
),
|
||||
confirmLabel: '确认发布',
|
||||
loadingLabel: '发布中...',
|
||||
action: async () => {
|
||||
await request(`/admin/position-groups/${selected.id}/publish`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
effectiveMonth: month,
|
||||
draftPlanId,
|
||||
draftVersion,
|
||||
draftUpdatedAt,
|
||||
}),
|
||||
})
|
||||
await onChanged(`${selected.name} 的岗位设置已发布,从 ${month} 起持续生效`)
|
||||
},
|
||||
})
|
||||
} catch (actionError) {
|
||||
onError(actionError instanceof Error ? actionError.message : '岗位设置发布失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const createGroup = async () => {
|
||||
if (!newName.trim()) {
|
||||
onError('请填写岗位名称')
|
||||
@ -839,29 +910,43 @@ function PositionConfigWorkspace({
|
||||
</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' ? '已归档' : '待配置'}
|
||||
{data.groups.map((group) => {
|
||||
const groupState = group.status === 'disabled'
|
||||
? '已删除'
|
||||
: group.draftPlan
|
||||
? group.effectivePlan ? '有待发布修改' : '待发布'
|
||||
: group.effectivePlan ? '已生效' : '待配置'
|
||||
const groupStateClass = group.status === 'disabled'
|
||||
? 'bg-[#8a8a8a]'
|
||||
: group.draftPlan || !group.effectivePlan ? 'bg-[#b26a00]' : 'bg-[#16794b]'
|
||||
return (
|
||||
<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} 人 · {groupState}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className={`h-2 w-2 rounded-full ${group.status === 'ready' ? 'bg-[#16794b]' : group.status === 'disabled' ? 'bg-[#8a8a8a]' : 'bg-[#b26a00]'}`} />
|
||||
</button>
|
||||
))}
|
||||
<span className={`h-2 w-2 rounded-full ${groupStateClass}`} />
|
||||
</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>
|
||||
<p className="mt-1 text-[11px] leading-5 text-[#5f5e5e]">
|
||||
{selected.effectivePlan
|
||||
? `当前设置从 ${selected.effectivePlan.effectiveMonth} 起生效${selected.draftPlan ? ',另有待发布修改' : ''}`
|
||||
: selected.draftPlan ? '岗位设置已保存,尚未发布' : '岗位尚未完成设置'}
|
||||
</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]">
|
||||
删除岗位
|
||||
@ -875,7 +960,7 @@ function PositionConfigWorkspace({
|
||||
<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]">
|
||||
@ -936,9 +1021,24 @@ function PositionConfigWorkspace({
|
||||
</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 ? '保存中...' : '保存修改'}
|
||||
<span className="text-[12px] font-semibold text-[#5f5e5e] sm:mr-auto">
|
||||
{dirty
|
||||
? '当前修改尚未保存'
|
||||
: selected.draftPlan
|
||||
? '草稿已保存,等待发布'
|
||||
: selected.effectivePlan ? '当前岗位设置已生效' : '请先选择考核项并保存'}
|
||||
</span>
|
||||
<button type="button" disabled={saving || selected.status === 'disabled' || !dirty} onClick={() => void save()} className="min-h-11 rounded border border-[#1a1c1c] px-5 text-[13px] font-bold text-[#1a1c1c] disabled:opacity-50">
|
||||
{saving ? '处理中...' : '保存草稿'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || selected.status === 'disabled' || dirty || !selected.draftPlan || templateVersionIds.length === 0}
|
||||
onClick={() => void publishPlan()}
|
||||
title={dirty ? '请先保存当前修改' : selected.draftPlan ? undefined : '当前没有待发布修改'}
|
||||
className="min-h-11 rounded bg-[#0050cb] px-5 text-[13px] font-bold text-white disabled:opacity-50"
|
||||
>
|
||||
发布岗位设置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -991,6 +1091,9 @@ function TemplateConfigWorkspace({
|
||||
|
||||
const version = selectedTemplate?.versions.find((item) => item.id === versionId) || null
|
||||
const editable = !version || version.status === 'draft'
|
||||
const selectedAvailability = selectedTemplate
|
||||
? getTemplateAvailability(selectedTemplate, month)
|
||||
: null
|
||||
const questionTotal = definition.questions.reduce(
|
||||
(sum, question) => sum + Number(question.maxScore || definition.questionMaxScore || 20),
|
||||
0,
|
||||
@ -1057,7 +1160,7 @@ function TemplateConfigWorkspace({
|
||||
title: `发布模板“${definition.name}”?`,
|
||||
description: (
|
||||
<div className="grid gap-1">
|
||||
<p>模板将从 {month} 起供岗位考核方案使用。</p>
|
||||
<p>模板将从 {month} 起成为可用模板,可直接添加到岗位考核项。</p>
|
||||
<p>发布后的版本不可直接修改;后续调整需要创建新的草稿版本。</p>
|
||||
</div>
|
||||
),
|
||||
@ -1148,17 +1251,32 @@ function TemplateConfigWorkspace({
|
||||
新建模板
|
||||
</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>
|
||||
))}
|
||||
{data.templates.map((template) => {
|
||||
const availability = getTemplateAvailability(template, month)
|
||||
const statusDetail = availability.isAvailable
|
||||
? `v${availability.availableVersion?.version} · ${availability.availableVersion?.effectiveMonth} 起${availability.draftVersion ? ' · 有未发布修改' : ''}`
|
||||
: availability.futureVersion
|
||||
? `${availability.futureVersion.effectiveMonth} 起可用`
|
||||
: availability.hasPublishedVersionWithoutMonth
|
||||
? '已发布版本缺少生效月份'
|
||||
: availability.draftVersion ? '仅有草稿,发布后可用' : '没有可用版本'
|
||||
return (
|
||||
<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="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-bold text-[#1a1c1c]">{template.versions[0]?.definition.name || template.name}</span>
|
||||
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold ${availability.isAvailable ? 'bg-[#eaf7ef] text-[#16794b]' : 'bg-[#f3f3f3] text-[#5f5e5e]'}`}>
|
||||
{availability.isAvailable ? '可用' : '不可用'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-1 block text-[11px] leading-4 text-[#727687]">{statusDetail}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</aside>
|
||||
|
||||
<div ref={editorRef} className="min-w-0 scroll-mt-4 p-4 sm:p-5">
|
||||
@ -1190,7 +1308,14 @@ function TemplateConfigWorkspace({
|
||||
<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="flex items-center gap-2 text-[11px] font-bold text-[#727687]">
|
||||
<span>当前模板</span>
|
||||
{selectedAvailability && (
|
||||
<span className={`rounded-full px-2 py-0.5 ${selectedAvailability.isAvailable ? 'bg-[#eaf7ef] text-[#16794b]' : 'bg-[#f0f0f0] text-[#5f5e5e]'}`}>
|
||||
{selectedAvailability.isAvailable ? '当前可用' : '当前不可用'}
|
||||
</span>
|
||||
)}
|
||||
</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} 个评审阶段
|
||||
@ -1215,7 +1340,7 @@ function TemplateConfigWorkspace({
|
||||
{([
|
||||
['basics', '1. 基本信息'],
|
||||
['questions', '2. 评分项'],
|
||||
['workflow', '3. 评审与计分'],
|
||||
['workflow', '3. 评分人设置'],
|
||||
] as const).map(([step, label]) => (
|
||||
<button
|
||||
key={step}
|
||||
@ -1245,17 +1370,21 @@ function TemplateConfigWorkspace({
|
||||
disabled={!editable}
|
||||
/>
|
||||
<label className="grid gap-1 text-[12px] font-semibold text-[#5f5e5e]">
|
||||
评分对象
|
||||
考核内容类型
|
||||
<select
|
||||
aria-label="考核内容类型"
|
||||
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>
|
||||
<option value="person_period">员工工作表现</option>
|
||||
<option value="visual_case">作品或案例</option>
|
||||
<option value="item_course">课程或事项</option>
|
||||
</select>
|
||||
<span className="font-normal leading-5 text-[#727687]">
|
||||
决定调研按员工表现、作品案例或一次具体事项来评分;具体考核人员在发起调研时选择。
|
||||
</span>
|
||||
</label>
|
||||
{definition.surveyType === 'visual_case' && (
|
||||
<AdminField
|
||||
@ -1306,200 +1435,6 @@ function TemplateConfigWorkspace({
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
@ -1633,39 +1568,111 @@ function WorkflowEditor({
|
||||
definition: TemplateDefinition
|
||||
editable: boolean
|
||||
request: RequestFn
|
||||
onChange: (definition: TemplateDefinition) => void
|
||||
onChange: (
|
||||
definition: TemplateDefinition | ((current: TemplateDefinition) => 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 reviewerResolutionSequenceRef = useRef<Record<string, number>>({})
|
||||
const updateStages = (
|
||||
nextStages: StageDraft[] | ((currentStages: StageDraft[]) => StageDraft[]),
|
||||
) => {
|
||||
onChange((currentDefinition) => {
|
||||
const resolvedStages = typeof nextStages === 'function'
|
||||
? nextStages(currentDefinition.workflow.stages)
|
||||
: nextStages
|
||||
const stages = resolvedStages.map((stage, index) => ({ ...stage, order: index }))
|
||||
const currentScoring = currentDefinition.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,
|
||||
}
|
||||
})()
|
||||
return {
|
||||
...currentDefinition,
|
||||
workflow: { ...currentDefinition.workflow, stages, scoring },
|
||||
}
|
||||
})
|
||||
}
|
||||
const stages = definition.workflow.stages
|
||||
const reviewerFlowSummary = stages.map((stage) => (
|
||||
`${stage.label}:${stage.reviewers.map((reviewer) => reviewer.employeeName || reviewerSourceLabel(reviewer.source)).join('、')}`
|
||||
)).join(' → ')
|
||||
|
||||
const changeReviewerSource = async (
|
||||
stageIndex: number,
|
||||
reviewerIndex: number,
|
||||
source: ReviewerSource,
|
||||
) => {
|
||||
const stageKey = stages[stageIndex]?.key
|
||||
if (!stageKey) return
|
||||
const resolutionKey = `${stageIndex}:${stageKey}:${reviewerIndex}`
|
||||
const sequence = (reviewerResolutionSequenceRef.current[resolutionKey] || 0) + 1
|
||||
reviewerResolutionSequenceRef.current[resolutionKey] = sequence
|
||||
const nextReviewer: ReviewerDraft = {
|
||||
source,
|
||||
label: reviewerSourceLabel(source),
|
||||
}
|
||||
updateStages((currentStages) => currentStages.map((stage, index) => (
|
||||
index === stageIndex && stage.key === stageKey
|
||||
? {
|
||||
...stage,
|
||||
reviewers: stage.reviewers.map((reviewer, indexInStage) => (
|
||||
indexInStage === reviewerIndex ? nextReviewer : reviewer
|
||||
)),
|
||||
}
|
||||
: stage
|
||||
)))
|
||||
if (source !== 'fixed_employee') return
|
||||
try {
|
||||
const employees = await request<Array<{ id: string; displayName: string }>>(
|
||||
'/employees?purpose=evaluator&limit=20&search=%E5%85%83%E6%B3%BD',
|
||||
)
|
||||
const exactMatches = employees.filter((employee) => employee.displayName.trim() === '元泽')
|
||||
if (
|
||||
reviewerResolutionSequenceRef.current[resolutionKey] !== sequence
|
||||
|| exactMatches.length !== 1
|
||||
) return
|
||||
updateStages((currentStages) => currentStages.map((stage, index) => (
|
||||
index === stageIndex && stage.key === stageKey
|
||||
? {
|
||||
...stage,
|
||||
reviewers: stage.reviewers.map((reviewer, indexInStage) => (
|
||||
indexInStage === reviewerIndex && reviewer.source === 'fixed_employee'
|
||||
? {
|
||||
...reviewer,
|
||||
label: '元泽',
|
||||
employeeId: exactMatches[0].id,
|
||||
employeeName: exactMatches[0].displayName,
|
||||
}
|
||||
: reviewer
|
||||
)),
|
||||
}
|
||||
: stage
|
||||
)))
|
||||
} catch {
|
||||
// 保留未解析状态,让管理员通过下方人员搜索完成选择。
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<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 border border-[#c2c6d8] bg-[#fafafa] px-3 py-3 text-[12px] leading-5 text-[#5f5e5e]">
|
||||
<span className="font-bold text-[#1a1c1c]">当前流程:</span>{reviewerFlowSummary || '尚未设置评分人'}
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3">
|
||||
{stages.map((stage, stageIndex) => (
|
||||
<div key={`${stage.key}-${stageIndex}`} className="border border-[#e2e2e2] p-4">
|
||||
@ -1698,19 +1705,17 @@ function WorkflowEditor({
|
||||
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))}
|
||||
onChange={(event) => void changeReviewerSource(
|
||||
stageIndex,
|
||||
reviewerIndex,
|
||||
event.target.value as ReviewerSource,
|
||||
)}
|
||||
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>
|
||||
<option value="fixed_employee">元泽</option>
|
||||
<option value="manual_selection">任何人(发起时选择)</option>
|
||||
<option value="self">本人自评</option>
|
||||
</select>
|
||||
{reviewer.source === 'fixed_employee' ? (
|
||||
<FixedReviewerPicker
|
||||
@ -1744,7 +1749,7 @@ function WorkflowEditor({
|
||||
)}
|
||||
</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>}
|
||||
{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>
|
||||
@ -1840,14 +1845,14 @@ function FixedReviewerPicker({
|
||||
return (
|
||||
<div className="relative min-w-0">
|
||||
<input
|
||||
aria-label="搜索固定评分人"
|
||||
aria-label="选择元泽人员档案"
|
||||
value={search || value.employeeName || ''}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value)
|
||||
onChange({ source: 'fixed_employee', label: '固定员工' })
|
||||
onChange({ source: 'fixed_employee', label: '元泽' })
|
||||
}}
|
||||
placeholder="搜索固定评分人"
|
||||
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 && (
|
||||
@ -1857,7 +1862,7 @@ function FixedReviewerPicker({
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange({ source: 'fixed_employee', label: '固定员工', employeeId: item.id, employeeName: item.displayName })
|
||||
onChange({ source: 'fixed_employee', label: '元泽', employeeId: item.id, employeeName: item.displayName })
|
||||
setSearch('')
|
||||
setItems([])
|
||||
}}
|
||||
|
||||
@ -0,0 +1,170 @@
|
||||
/** @jest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import PerformanceSurveyPage from './[[...path]]/page'
|
||||
|
||||
const mockRouterReplace = jest.fn()
|
||||
const mockRouterPush = jest.fn()
|
||||
let mockSearchParams = new URLSearchParams()
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
useParams: () => ({ path: [] }),
|
||||
useRouter: () => ({
|
||||
push: mockRouterPush,
|
||||
replace: mockRouterReplace,
|
||||
}),
|
||||
useSearchParams: () => mockSearchParams,
|
||||
}))
|
||||
|
||||
jest.mock('next-auth/react', () => ({
|
||||
useSession: () => ({
|
||||
data: {
|
||||
user: {
|
||||
id: 'user-admin',
|
||||
name: '管理员',
|
||||
email: 'admin@example.com',
|
||||
},
|
||||
expires: '2099-01-01T00:00:00.000Z',
|
||||
},
|
||||
status: 'authenticated',
|
||||
}),
|
||||
}))
|
||||
|
||||
const templateDefinition = {
|
||||
code: 'visual_review',
|
||||
name: '视觉案例评分',
|
||||
shortName: '视觉评分',
|
||||
description: '视觉案例评分',
|
||||
surveyType: 'visual_case',
|
||||
weight: 1,
|
||||
maxTotal: 60,
|
||||
questionMaxScore: 20,
|
||||
requiredWorkItemCount: 3,
|
||||
workItemImageRequired: true,
|
||||
questions: [{ id: 'quality', title: '作品质量', maxScore: 20 }],
|
||||
scoreBands: [],
|
||||
workflow: {
|
||||
stages: [{
|
||||
key: 'review',
|
||||
label: '终评',
|
||||
order: 0,
|
||||
reviewers: [{ source: 'direct_manager', label: '直属主管' }],
|
||||
}],
|
||||
scoring: { mode: 'final_reviewer', stageKey: 'review', reviewerSource: 'direct_manager' },
|
||||
},
|
||||
}
|
||||
|
||||
const adminConfig = {
|
||||
month: '2026-07',
|
||||
groups: [{
|
||||
id: 'group-1',
|
||||
code: 'brand_designer',
|
||||
name: '品牌设计师',
|
||||
sortOrder: 10,
|
||||
isActive: true,
|
||||
status: 'ready',
|
||||
memberCount: 2,
|
||||
rules: [],
|
||||
effectivePlan: {
|
||||
id: 'plan-1',
|
||||
version: 1,
|
||||
effectiveMonth: '2026-07',
|
||||
templateVersionIds: ['version-1'],
|
||||
},
|
||||
draftPlan: null,
|
||||
}],
|
||||
templates: [{
|
||||
id: 'template-1',
|
||||
code: 'visual_review',
|
||||
name: '视觉案例评分',
|
||||
description: '视觉案例评分',
|
||||
surveyType: 'visual_case',
|
||||
isActive: true,
|
||||
versions: [{
|
||||
id: 'version-1',
|
||||
version: 1,
|
||||
status: 'published',
|
||||
effectiveMonth: '2026-07',
|
||||
definition: templateDefinition,
|
||||
}],
|
||||
}],
|
||||
issues: {
|
||||
unmatched: [],
|
||||
conflicts: [],
|
||||
},
|
||||
}
|
||||
|
||||
function jsonResponse(data: unknown) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data }),
|
||||
text: async () => '',
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
describe('考核调研顶层导航保护', () => {
|
||||
const originalFetch = global.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
mockRouterReplace.mockReset()
|
||||
mockRouterPush.mockReset()
|
||||
mockSearchParams = new URLSearchParams('tab=manage&adminWorkspace=positions&adminMonth=2026-07')
|
||||
global.fetch = jest.fn(async (input) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/admin/config-audit-logs')) return jsonResponse({ items: [] })
|
||||
if (url.includes('/admin/config?')) return jsonResponse(adminConfig)
|
||||
if (url.endsWith('/me')) {
|
||||
return jsonResponse({
|
||||
user: { id: 'user-admin', name: '管理员', email: 'admin@example.com' },
|
||||
employee: { id: 'employee-admin', displayName: '管理员' },
|
||||
permissions: {
|
||||
canCreate: true,
|
||||
isSystemAdmin: true,
|
||||
isDepartmentAdmin: false,
|
||||
isLeadership: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (url.includes('/departments?purpose=evaluator')) return jsonResponse([])
|
||||
if (url.endsWith('/surveys')) return jsonResponse([])
|
||||
return jsonResponse([])
|
||||
}) as jest.Mock
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: jest.fn().mockReturnValue({ matches: false }),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
test('岗位存在未保存修改时切换个人看板会使用统一弹窗保护', async () => {
|
||||
render(<PerformanceSurveyPage />)
|
||||
|
||||
const nameInput = await screen.findByLabelText('岗位名称')
|
||||
fireEvent.change(nameInput, { target: { value: '品牌设计师(修改中)' } })
|
||||
expect(await screen.findByText('当前修改尚未保存')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: '个人看板' })[0])
|
||||
const firstDialog = await screen.findByRole('alertdialog', { name: '放弃尚未保存的修改?' })
|
||||
fireEvent.click(within(firstDialog).getByRole('button', { name: '取消' }))
|
||||
await waitFor(() => expect(screen.queryByRole('alertdialog', { name: '放弃尚未保存的修改?' })).not.toBeInTheDocument())
|
||||
expect(mockRouterReplace).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('tab=personal'),
|
||||
expect.anything(),
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: '个人看板' })[0])
|
||||
const secondDialog = await screen.findByRole('alertdialog', { name: '放弃尚未保存的修改?' })
|
||||
fireEvent.click(within(secondDialog).getByRole('button', { name: '放弃并离开' }))
|
||||
|
||||
await waitFor(() => expect(mockRouterReplace).toHaveBeenCalledWith(
|
||||
'/performance-survey?tab=personal&adminWorkspace=positions',
|
||||
{ scroll: false },
|
||||
))
|
||||
})
|
||||
})
|
||||
@ -15,6 +15,7 @@ import {
|
||||
} from '@/lib/performance-survey-response-progress'
|
||||
import { groupVisualScoresByEvaluator } from '@/lib/performance-survey-result-grouping'
|
||||
import { PerformanceSurveyAdminConfig } from '@/app/performance-survey/PerformanceSurveyAdminConfig'
|
||||
import { useConfirmActionDialog } from '@/components/ConfirmActionDialog'
|
||||
|
||||
type ApiState<T> = {
|
||||
loading: boolean
|
||||
@ -720,6 +721,11 @@ function Dashboard({ me, permissionsLoading }: { me: any | null; permissionsLoad
|
||||
const requestedTab = normalizePerformanceSurveyDashboardTab(requestedTabRaw)
|
||||
const searchParamsKey = searchParams.toString()
|
||||
const [tab, setTab] = useState<PerformanceSurveyDashboardTab>(requestedTab)
|
||||
const [manageConfigurationDirty, setManageConfigurationDirty] = useState(false)
|
||||
const {
|
||||
confirmAction: confirmDashboardNavigation,
|
||||
confirmActionDialog: dashboardNavigationDialog,
|
||||
} = useConfirmActionDialog()
|
||||
const displayName = me?.employee?.flowerName || me?.employee?.displayName || me?.user?.name || '当前用户'
|
||||
const avatar = me?.user?.avatar || me?.user?.image || ''
|
||||
const canCreateSurvey = Boolean(me?.permissions?.canCreate)
|
||||
@ -728,26 +734,63 @@ function Dashboard({ me, permissionsLoading }: { me: any | null; permissionsLoad
|
||||
() => canCreateSurvey ? ['personal', 'public', 'manage'] : ['personal', 'public'],
|
||||
[canCreateSurvey],
|
||||
)
|
||||
const confirmLeaveManage = useCallback(async () => {
|
||||
if (tab !== 'manage' || !manageConfigurationDirty) return true
|
||||
const result = await confirmDashboardNavigation({
|
||||
title: '放弃尚未保存的修改?',
|
||||
description: '离开调研管理后,本次尚未保存的配置不会保留。',
|
||||
confirmLabel: '放弃并离开',
|
||||
tone: 'danger',
|
||||
})
|
||||
return result.confirmed
|
||||
}, [confirmDashboardNavigation, manageConfigurationDirty, tab])
|
||||
|
||||
useEffect(() => {
|
||||
if (!permissionsResolved) return
|
||||
if (requestedTab === 'manage' && !canCreateSurvey) {
|
||||
setTab('personal')
|
||||
const query = new URLSearchParams(searchParamsKey)
|
||||
query.set('tab', 'personal')
|
||||
query.delete('prefillMonth')
|
||||
query.delete('prefillTemplate')
|
||||
router.replace(`/performance-survey?${query.toString()}`, { scroll: false })
|
||||
return
|
||||
let cancelled = false
|
||||
const syncRequestedTab = async () => {
|
||||
if (requestedTab === 'manage' && !canCreateSurvey) {
|
||||
setManageConfigurationDirty(false)
|
||||
setTab('personal')
|
||||
const query = new URLSearchParams(searchParamsKey)
|
||||
query.set('tab', 'personal')
|
||||
query.delete('prefillMonth')
|
||||
query.delete('prefillTemplate')
|
||||
router.replace(`/performance-survey?${query.toString()}`, { scroll: false })
|
||||
return
|
||||
}
|
||||
if (availableTabIds.includes(requestedTab) && tab !== requestedTab) {
|
||||
if (!await confirmLeaveManage()) {
|
||||
if (cancelled) return
|
||||
const query = new URLSearchParams(searchParamsKey)
|
||||
query.set('tab', tab)
|
||||
router.replace(`/performance-survey?${query.toString()}`, { scroll: false })
|
||||
return
|
||||
}
|
||||
if (cancelled) return
|
||||
setManageConfigurationDirty(false)
|
||||
setTab(requestedTab)
|
||||
return
|
||||
}
|
||||
if (!availableTabIds.includes(tab)) {
|
||||
setManageConfigurationDirty(false)
|
||||
setTab('personal')
|
||||
}
|
||||
}
|
||||
if (availableTabIds.includes(requestedTab) && tab !== requestedTab) {
|
||||
setTab(requestedTab)
|
||||
return
|
||||
void syncRequestedTab()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
if (!availableTabIds.includes(tab)) {
|
||||
setTab('personal')
|
||||
}
|
||||
}, [availableTabIds, canCreateSurvey, permissionsResolved, requestedTab, router, searchParamsKey, tab])
|
||||
}, [
|
||||
availableTabIds,
|
||||
canCreateSurvey,
|
||||
confirmLeaveManage,
|
||||
permissionsResolved,
|
||||
requestedTab,
|
||||
router,
|
||||
searchParamsKey,
|
||||
tab,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (requestedTabRaw === requestedTab) return
|
||||
@ -758,7 +801,9 @@ function Dashboard({ me, permissionsLoading }: { me: any | null; permissionsLoad
|
||||
router.replace(`/performance-survey?${query.toString()}`, { scroll: false })
|
||||
}, [requestedTab, requestedTabRaw, router, searchParamsKey])
|
||||
|
||||
const selectTab = (nextTab: PerformanceSurveyDashboardTab) => {
|
||||
const selectTab = async (nextTab: PerformanceSurveyDashboardTab) => {
|
||||
if (nextTab === tab || !await confirmLeaveManage()) return
|
||||
setManageConfigurationDirty(false)
|
||||
setTab(nextTab)
|
||||
const query = new URLSearchParams()
|
||||
query.set('tab', nextTab)
|
||||
@ -795,7 +840,7 @@ function Dashboard({ me, permissionsLoading }: { me: any | null; permissionsLoad
|
||||
activeTab={tab}
|
||||
avatar={avatar}
|
||||
displayName={displayName}
|
||||
onSelectTab={selectTab}
|
||||
onSelectTab={(nextTab) => void selectTab(nextTab)}
|
||||
tabItems={tabItems}
|
||||
/>
|
||||
<main className="mx-auto w-full max-w-[1200px] bg-white px-4 py-8 pb-24 text-[#1a1c1c] sm:px-6 sm:pb-10">
|
||||
@ -815,15 +860,18 @@ function Dashboard({ me, permissionsLoading }: { me: any | null; permissionsLoad
|
||||
|| me?.permissions?.isDepartmentAdmin
|
||||
|| me?.permissions?.isLeadership,
|
||||
)}
|
||||
configurationDirty={manageConfigurationDirty}
|
||||
onConfigurationDirtyChange={setManageConfigurationDirty}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
{dashboardNavigationDialog}
|
||||
<nav className={`fixed inset-x-0 bottom-0 z-30 grid h-[68px] ${tabItems.length === 3 ? 'grid-cols-3' : 'grid-cols-2'} border-t border-[#e2e2e2] bg-white sm:hidden`}>
|
||||
{tabItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => selectTab(item.id)}
|
||||
onClick={() => void selectTab(item.id)}
|
||||
aria-current={tab === item.id ? 'page' : undefined}
|
||||
className={`flex items-center justify-center text-[12px] font-semibold ${
|
||||
tab === item.id ? 'text-[#0050cb]' : 'text-[#5f5e5e]'
|
||||
@ -2766,7 +2814,6 @@ type PerformanceSurveyAdminWorkspace =
|
||||
| 'positions'
|
||||
| 'people'
|
||||
| 'templates'
|
||||
| 'plans'
|
||||
| 'operations'
|
||||
|
||||
const PERFORMANCE_SURVEY_ADMIN_WORKSPACES: PerformanceSurveyAdminWorkspace[] = [
|
||||
@ -2775,7 +2822,6 @@ const PERFORMANCE_SURVEY_ADMIN_WORKSPACES: PerformanceSurveyAdminWorkspace[] = [
|
||||
'positions',
|
||||
'people',
|
||||
'templates',
|
||||
'plans',
|
||||
'operations',
|
||||
]
|
||||
|
||||
@ -2783,24 +2829,34 @@ function ManagePanel({
|
||||
currentEmployee,
|
||||
isSystemAdmin,
|
||||
canSelectAnyPosition,
|
||||
configurationDirty,
|
||||
onConfigurationDirtyChange,
|
||||
}: {
|
||||
currentEmployee: Employee | null
|
||||
isSystemAdmin: boolean
|
||||
canSelectAnyPosition: boolean
|
||||
configurationDirty: boolean
|
||||
onConfigurationDirtyChange: (dirty: boolean) => void
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const departments = useApi<DepartmentOption[]>('/departments?purpose=evaluator', [])
|
||||
const surveys = useApi<any>('/surveys', [])
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const requestedWorkspace = String(searchParams.get('adminWorkspace') || '').trim() as PerformanceSurveyAdminWorkspace
|
||||
const {
|
||||
confirmAction: confirmManageNavigation,
|
||||
confirmActionDialog: manageNavigationDialog,
|
||||
} = useConfirmActionDialog()
|
||||
const requestedWorkspaceValue = String(searchParams.get('adminWorkspace') || '').trim()
|
||||
const requestedWorkspace = (
|
||||
requestedWorkspaceValue === 'plans' ? 'positions' : requestedWorkspaceValue
|
||||
) as PerformanceSurveyAdminWorkspace
|
||||
const requestedAdminMonth = String(searchParams.get('adminMonth') || '').trim()
|
||||
const adminMonth = /^\d{4}-\d{2}$/.test(requestedAdminMonth) ? requestedAdminMonth : getCurrentMonthValue()
|
||||
const initialWorkspace = isSystemAdmin && PERFORMANCE_SURVEY_ADMIN_WORKSPACES.includes(requestedWorkspace)
|
||||
? requestedWorkspace
|
||||
: isSystemAdmin ? 'overview' : 'launch'
|
||||
const [workspace, setWorkspace] = useState<PerformanceSurveyAdminWorkspace>(initialWorkspace)
|
||||
const [configurationDirty, setConfigurationDirty] = useState(false)
|
||||
const notifications = useApi<any[]>(
|
||||
isSystemAdmin && workspace === 'operations' ? '/admin/notifications?limit=50' : null,
|
||||
[workspace, isSystemAdmin],
|
||||
@ -2809,40 +2865,67 @@ function ManagePanel({
|
||||
isSystemAdmin && workspace === 'operations' ? '/admin/notifications/status' : null,
|
||||
[workspace, isSystemAdmin],
|
||||
)
|
||||
const confirmLeaveConfiguration = useCallback(async () => {
|
||||
if (!configurationDirty) return true
|
||||
const result = await confirmManageNavigation({
|
||||
title: '放弃尚未保存的修改?',
|
||||
description: '离开当前页面后,本次尚未保存的配置不会保留。',
|
||||
confirmLabel: '放弃并离开',
|
||||
tone: 'danger',
|
||||
})
|
||||
return result.confirmed
|
||||
}, [configurationDirty, confirmManageNavigation])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSystemAdmin || requestedWorkspaceValue !== 'plans') return
|
||||
const query = new URLSearchParams(searchParams.toString())
|
||||
query.set('tab', 'manage')
|
||||
query.set('adminWorkspace', 'positions')
|
||||
router.replace(`/performance-survey?${query.toString()}`, { scroll: false })
|
||||
}, [isSystemAdmin, requestedWorkspaceValue, router, searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSystemAdmin) {
|
||||
if (configurationDirty) onConfigurationDirtyChange(false)
|
||||
if (workspace !== 'launch') setWorkspace('launch')
|
||||
return
|
||||
}
|
||||
if (PERFORMANCE_SURVEY_ADMIN_WORKSPACES.includes(requestedWorkspace) && requestedWorkspace !== workspace) {
|
||||
if (
|
||||
configurationDirty
|
||||
&& typeof window !== 'undefined'
|
||||
&& !window.confirm('当前配置尚未保存,确定离开吗?')
|
||||
) {
|
||||
if (!PERFORMANCE_SURVEY_ADMIN_WORKSPACES.includes(requestedWorkspace) || requestedWorkspace === workspace) {
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const syncRequestedWorkspace = async () => {
|
||||
if (!await confirmLeaveConfiguration()) {
|
||||
if (cancelled) return
|
||||
const query = new URLSearchParams(searchParams.toString())
|
||||
query.set('tab', 'manage')
|
||||
query.set('adminWorkspace', workspace)
|
||||
router.replace(`/performance-survey?${query.toString()}`, { scroll: false })
|
||||
return
|
||||
}
|
||||
setConfigurationDirty(false)
|
||||
if (cancelled) return
|
||||
onConfigurationDirtyChange(false)
|
||||
setWorkspace(requestedWorkspace)
|
||||
}
|
||||
}, [configurationDirty, isSystemAdmin, requestedWorkspace, router, searchParams, workspace])
|
||||
|
||||
const selectWorkspace = (nextWorkspace: PerformanceSurveyAdminWorkspace) => {
|
||||
if (
|
||||
nextWorkspace !== workspace
|
||||
&& configurationDirty
|
||||
&& typeof window !== 'undefined'
|
||||
&& !window.confirm('当前配置尚未保存,确定离开吗?')
|
||||
) {
|
||||
return
|
||||
void syncRequestedWorkspace()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [
|
||||
confirmLeaveConfiguration,
|
||||
configurationDirty,
|
||||
isSystemAdmin,
|
||||
onConfigurationDirtyChange,
|
||||
requestedWorkspace,
|
||||
router,
|
||||
searchParams,
|
||||
workspace,
|
||||
])
|
||||
|
||||
const selectWorkspace = async (nextWorkspace: PerformanceSurveyAdminWorkspace) => {
|
||||
if (nextWorkspace === workspace || !await confirmLeaveConfiguration()) return
|
||||
setMessage(null)
|
||||
setConfigurationDirty(false)
|
||||
onConfigurationDirtyChange(false)
|
||||
setWorkspace(nextWorkspace)
|
||||
const query = new URLSearchParams(searchParams.toString())
|
||||
query.set('tab', 'manage')
|
||||
@ -2871,14 +2954,13 @@ function ManagePanel({
|
||||
['positions', '岗位'],
|
||||
['people', '人员分配'],
|
||||
['templates', '模板'],
|
||||
['plans', '岗位考核方案'],
|
||||
['launch', '发起调研'],
|
||||
['operations', '运行维护'],
|
||||
] as const).map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => selectWorkspace(id)}
|
||||
onClick={() => void selectWorkspace(id)}
|
||||
aria-current={workspace === id ? 'page' : undefined}
|
||||
className={`min-h-11 shrink-0 rounded px-3 text-[12px] font-bold transition-colors ${
|
||||
workspace === id
|
||||
@ -2898,7 +2980,7 @@ function ManagePanel({
|
||||
canSelectAnyPosition={canSelectAnyPosition}
|
||||
defaultMonth={adminMonth}
|
||||
onMonthChange={selectAdminMonth}
|
||||
onAdminNavigate={isSystemAdmin ? selectWorkspace : undefined}
|
||||
onAdminNavigate={isSystemAdmin ? (nextWorkspace) => void selectWorkspace(nextWorkspace) : undefined}
|
||||
onCreated={async () => {
|
||||
setMessage('已发布调研')
|
||||
await surveys.refresh()
|
||||
@ -2921,10 +3003,11 @@ function ManagePanel({
|
||||
request={apiRequest}
|
||||
month={adminMonth}
|
||||
onMonthChange={selectAdminMonth}
|
||||
onNavigate={(nextWorkspace) => selectWorkspace(nextWorkspace)}
|
||||
onDirtyChange={setConfigurationDirty}
|
||||
onNavigate={(nextWorkspace) => void selectWorkspace(nextWorkspace)}
|
||||
onDirtyChange={onConfigurationDirtyChange}
|
||||
/>
|
||||
)}
|
||||
{manageNavigationDialog}
|
||||
{message && (
|
||||
<div className="border border-[#b3c5ff] bg-[#f8faff] px-4 py-3 text-sm font-semibold text-[#0050cb]">
|
||||
{message}
|
||||
@ -3309,13 +3392,12 @@ function CreateSurveyForm({
|
||||
? `${assessmentMonth} 暂无可发起的岗位`
|
||||
: '所选岗位还没有可用考核模板'}
|
||||
</div>
|
||||
<p className="mt-1">请先补齐岗位匹配、人员归属、模板和岗位考核方案,再回来发起调研。</p>
|
||||
<p className="mt-1">请先补齐岗位设置、人员归属和可用模板,再回来发起调研。</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{([
|
||||
['positions', '检查岗位'],
|
||||
['positions', '检查岗位设置'],
|
||||
['people', '检查人员'],
|
||||
['templates', '检查模板'],
|
||||
['plans', '检查岗位方案'],
|
||||
] as const).map(([target, label]) => (
|
||||
<button
|
||||
key={target}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user