feat: 完善考核调研管理员维护后台
This commit is contained in:
parent
9d812f8c84
commit
770a39b127
@ -37,6 +37,16 @@ import {
|
||||
savePerformanceSurveyAdminTemplate,
|
||||
clonePerformanceSurveyAdminTemplateVersion,
|
||||
publishPerformanceSurveyAdminTemplateVersion,
|
||||
archivePerformanceSurveyAdminPositionAssignment,
|
||||
copyPerformanceSurveyAdminPositionGroup,
|
||||
createPerformanceSurveyAdminPositionAssignments,
|
||||
createPerformanceSurveyAdminPositionGroup,
|
||||
listPerformanceSurveyAdminConfigAuditLogs,
|
||||
listPerformanceSurveyAdminPositionAssignments,
|
||||
previewPerformanceSurveyAdminPositionGroup,
|
||||
removePerformanceSurveyAdminPositionGroup,
|
||||
removePerformanceSurveyAdminTemplate,
|
||||
updatePerformanceSurveyAdminPositionAssignment,
|
||||
} from '@/lib/performance-survey-service'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
@ -165,6 +175,24 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
return ok(await getPerformanceSurveyNotificationStatus(session))
|
||||
}
|
||||
|
||||
if (
|
||||
path[0] === 'admin'
|
||||
&& path[1] === 'position-groups'
|
||||
&& path[2]
|
||||
&& path[3] === 'preview'
|
||||
&& path.length === 4
|
||||
) {
|
||||
return ok(await previewPerformanceSurveyAdminPositionGroup(session, path[2], url.searchParams))
|
||||
}
|
||||
|
||||
if (path[0] === 'admin' && path[1] === 'position-assignments' && path.length === 2) {
|
||||
return ok(await listPerformanceSurveyAdminPositionAssignments(session, url.searchParams))
|
||||
}
|
||||
|
||||
if (path[0] === 'admin' && path[1] === 'config-audit-logs' && path.length === 2) {
|
||||
return ok(await listPerformanceSurveyAdminConfigAuditLogs(session, url.searchParams))
|
||||
}
|
||||
|
||||
if (path[0] === 'admin' && path[1] === 'config') {
|
||||
return ok(await getPerformanceSurveyAdminConfiguration(session, url.searchParams))
|
||||
}
|
||||
@ -224,10 +252,34 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
return ok(await syncPerformanceSurveyEmployeeProfiles(session, body))
|
||||
}
|
||||
|
||||
if (path[0] === 'admin' && path[1] === 'position-groups' && path[2] && path[3] === 'publish') {
|
||||
if (path[0] === 'admin' && path[1] === 'position-groups' && path.length === 2) {
|
||||
return ok(await createPerformanceSurveyAdminPositionGroup(session, body), { status: 201 })
|
||||
}
|
||||
|
||||
if (
|
||||
path[0] === 'admin'
|
||||
&& path[1] === 'position-groups'
|
||||
&& path[2]
|
||||
&& path[3] === 'copy'
|
||||
&& path.length === 4
|
||||
) {
|
||||
return ok(await copyPerformanceSurveyAdminPositionGroup(session, path[2], body), { status: 201 })
|
||||
}
|
||||
|
||||
if (
|
||||
path[0] === 'admin'
|
||||
&& path[1] === 'position-groups'
|
||||
&& path[2]
|
||||
&& path[3] === 'publish'
|
||||
&& path.length === 4
|
||||
) {
|
||||
return ok(await publishPerformanceSurveyAdminPositionGroup(session, path[2], body))
|
||||
}
|
||||
|
||||
if (path[0] === 'admin' && path[1] === 'position-assignments' && path.length === 2) {
|
||||
return ok(await createPerformanceSurveyAdminPositionAssignments(session, body), { status: 201 })
|
||||
}
|
||||
|
||||
if (path[0] === 'admin' && path[1] === 'templates' && path.length === 2) {
|
||||
return ok(await savePerformanceSurveyAdminTemplate(session, body), { status: 201 })
|
||||
}
|
||||
@ -272,6 +324,34 @@ export async function PATCH(request: Request, context: RouteContext) {
|
||||
return ok(await savePerformanceSurveyAdminTemplate(session, { ...body, versionId: path[2] }))
|
||||
}
|
||||
|
||||
if (path[0] === 'admin' && path[1] === 'position-assignments' && path[2] && path.length === 3) {
|
||||
return ok(await updatePerformanceSurveyAdminPositionAssignment(session, path[2], body))
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: false, error: '接口不存在' }, { status: 404 })
|
||||
} catch (error) {
|
||||
return errorResponse(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const session = await getSession(request)
|
||||
const path = getPath(context)
|
||||
const body = await request.json().catch(() => ({}))
|
||||
|
||||
if (path[0] === 'admin' && path[1] === 'position-groups' && path[2] && path.length === 3) {
|
||||
return ok(await removePerformanceSurveyAdminPositionGroup(session, path[2], body))
|
||||
}
|
||||
|
||||
if (path[0] === 'admin' && path[1] === 'templates' && path[2] && path.length === 3) {
|
||||
return ok(await removePerformanceSurveyAdminTemplate(session, path[2], body))
|
||||
}
|
||||
|
||||
if (path[0] === 'admin' && path[1] === 'position-assignments' && path[2] && path.length === 3) {
|
||||
return ok(await archivePerformanceSurveyAdminPositionAssignment(session, path[2], body))
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: false, error: '接口不存在' }, { status: 404 })
|
||||
} catch (error) {
|
||||
return errorResponse(error)
|
||||
|
||||
@ -3,6 +3,30 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { PerformanceSurveyAdminConfig } from './PerformanceSurveyAdminConfig'
|
||||
|
||||
const templateDefinition = {
|
||||
code: 'visual_review',
|
||||
name: '视觉案例评分',
|
||||
shortName: '视觉评分',
|
||||
description: '视觉案例评分',
|
||||
surveyType: 'visual_case',
|
||||
weight: 1,
|
||||
maxTotal: 120,
|
||||
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' },
|
||||
},
|
||||
} as const
|
||||
|
||||
const config = {
|
||||
month: '2026-07',
|
||||
groups: [{
|
||||
@ -27,6 +51,17 @@ const config = {
|
||||
templateVersionIds: ['version-1'],
|
||||
},
|
||||
draftPlan: null,
|
||||
}, {
|
||||
id: 'group-2',
|
||||
code: 'ai_designer',
|
||||
name: 'AI 技术设计师',
|
||||
sortOrder: 20,
|
||||
isActive: true,
|
||||
status: 'pending',
|
||||
memberCount: 2,
|
||||
rules: [],
|
||||
effectivePlan: null,
|
||||
draftPlan: null,
|
||||
}],
|
||||
templates: [{
|
||||
id: 'template-1',
|
||||
@ -40,28 +75,26 @@ const config = {
|
||||
version: 1,
|
||||
status: 'published',
|
||||
effectiveMonth: '2026-07',
|
||||
definition: templateDefinition,
|
||||
}],
|
||||
}, {
|
||||
id: 'template-2',
|
||||
code: 'course_share',
|
||||
name: '课程分享',
|
||||
description: '课程分享',
|
||||
surveyType: 'item_course',
|
||||
isActive: true,
|
||||
versions: [{
|
||||
id: 'version-2',
|
||||
version: 1,
|
||||
status: 'published',
|
||||
effectiveMonth: '2026-07',
|
||||
definition: {
|
||||
code: 'visual_review',
|
||||
name: '视觉案例评分',
|
||||
shortName: '视觉评分',
|
||||
description: '视觉案例评分',
|
||||
surveyType: 'visual_case',
|
||||
weight: 1,
|
||||
maxTotal: 120,
|
||||
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' },
|
||||
},
|
||||
...templateDefinition,
|
||||
code: 'course_share',
|
||||
name: '课程分享',
|
||||
shortName: '课程分享',
|
||||
surveyType: 'item_course',
|
||||
},
|
||||
}],
|
||||
}],
|
||||
@ -69,42 +102,161 @@ const config = {
|
||||
unmatched: [{ id: 'employee-1', displayName: '待配置人员', departmentPath: '集团/品牌中心', positionTitle: '设计师' }],
|
||||
conflicts: [],
|
||||
},
|
||||
} as const
|
||||
} as any
|
||||
|
||||
function createRequest() {
|
||||
return jest.fn(async (path: string, options?: RequestInit) => {
|
||||
if (path.startsWith('/admin/config?')) return config
|
||||
if (path.startsWith('/admin/config-audit-logs')) return { items: [] }
|
||||
if (path.startsWith('/admin/position-assignments?')) {
|
||||
return {
|
||||
items: [{
|
||||
id: 'employee-1',
|
||||
employeeId: 'employee-1',
|
||||
displayName: '张三',
|
||||
departmentName: '品牌中心',
|
||||
positionTitle: '设计师',
|
||||
participation: 'unassigned',
|
||||
}],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
}
|
||||
}
|
||||
if (path.endsWith('/preview?month=2026-07')) {
|
||||
return {
|
||||
memberCount: 6,
|
||||
conflictCount: 0,
|
||||
draftPlanId: 'plan-1',
|
||||
draftVersion: 2,
|
||||
draftUpdatedAt: '2026-07-23T10:00:00.000Z',
|
||||
}
|
||||
}
|
||||
if (path === '/admin/position-groups' && options?.method === 'POST') return { id: 'group-3' }
|
||||
if (path.endsWith('/clone')) return { id: 'draft-version-2' }
|
||||
return {}
|
||||
})
|
||||
}
|
||||
|
||||
describe('PerformanceSurveyAdminConfig', () => {
|
||||
test('岗位配置使用岗位列表和右侧编辑区,并展示配置问题', async () => {
|
||||
const request = jest.fn().mockResolvedValue(config)
|
||||
render(<PerformanceSurveyAdminConfig mode="positions" request={request} />)
|
||||
beforeEach(() => {
|
||||
jest.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: jest.fn().mockReturnValue({ matches: false }),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
test('概览展示岗位、人员、模板和月度方案状态', async () => {
|
||||
const request = createRequest()
|
||||
render(<PerformanceSurveyAdminConfig mode="overview" request={request} />)
|
||||
|
||||
expect(screen.getByText('配置加载中...')).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getByText('品牌设计师')).toBeInTheDocument())
|
||||
expect(screen.getByText('人员匹配规则')).toBeInTheDocument()
|
||||
expect(screen.getByText('考核项与顺序')).toBeInTheDocument()
|
||||
expect(screen.getByText(/未匹配 1 人/)).toBeInTheDocument()
|
||||
expect(screen.getByText('待配置人员')).toBeInTheDocument()
|
||||
expect(request).toHaveBeenCalledWith(expect.stringContaining('/admin/config?month='))
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: '2026-07 配置概览' })).toBeInTheDocument())
|
||||
expect(screen.getByRole('rowheader', { name: '岗位' })).toBeInTheDocument()
|
||||
expect(screen.getByText('未匹配 1 人')).toBeInTheDocument()
|
||||
expect(request).toHaveBeenCalledWith(expect.stringContaining('/admin/config-audit-logs'))
|
||||
})
|
||||
|
||||
test('模板管理展示版本状态、评分项和评审流程', async () => {
|
||||
const request = jest.fn().mockResolvedValue(config)
|
||||
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
|
||||
test('岗位编辑产生 dirty 状态并保护岗位切换', async () => {
|
||||
const request = createRequest()
|
||||
const onDirtyChange = jest.fn()
|
||||
render(<PerformanceSurveyAdminConfig mode="positions" request={request} onDirtyChange={onDirtyChange} />)
|
||||
|
||||
await waitFor(() => expect(screen.getAllByText('视觉案例评分').length).toBeGreaterThan(0))
|
||||
expect(screen.getByText('评分项')).toBeInTheDocument()
|
||||
expect(screen.getByText('评审流程')).toBeInTheDocument()
|
||||
expect(screen.getByText(/v1 · 已发布/)).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getByLabelText('岗位名称')).toHaveValue('品牌设计师'))
|
||||
fireEvent.change(screen.getByLabelText('岗位名称'), { target: { value: '品牌视觉设计师' } })
|
||||
expect(onDirtyChange).toHaveBeenLastCalledWith(true)
|
||||
|
||||
;(window.confirm as jest.Mock).mockReturnValueOnce(false)
|
||||
fireEvent.click(screen.getByRole('button', { name: /AI 技术设计师/ }))
|
||||
expect(screen.getByLabelText('岗位名称')).toHaveValue('品牌视觉设计师')
|
||||
|
||||
;(window.confirm as jest.Mock).mockReturnValueOnce(true)
|
||||
fireEvent.click(screen.getByRole('button', { name: /AI 技术设计师/ }))
|
||||
await waitFor(() => expect(screen.getByLabelText('岗位名称')).toHaveValue('AI 技术设计师'))
|
||||
})
|
||||
|
||||
test('岗位发布失败时直接展示服务端业务错误', async () => {
|
||||
const request = jest.fn().mockImplementation((path: string) => (
|
||||
path.startsWith('/admin/config')
|
||||
? Promise.resolve(config)
|
||||
: Promise.reject(new Error('岗位规则存在重复匹配人员'))
|
||||
))
|
||||
test('新增岗位使用确定的 collection POST 契约', async () => {
|
||||
const request = createRequest()
|
||||
render(<PerformanceSurveyAdminConfig mode="positions" request={request} />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('品牌设计师')).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '新增岗位' }))
|
||||
fireEvent.change(screen.getByLabelText('新岗位代码'), { target: { value: 'new_designer' } })
|
||||
fireEvent.change(screen.getByLabelText('新岗位名称'), { target: { value: '新设计岗位' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认创建' }))
|
||||
|
||||
await waitFor(() => expect(request).toHaveBeenCalledWith('/admin/position-groups', expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code: 'new_designer', name: '新设计岗位', effectiveMonth: '2026-07' }),
|
||||
})))
|
||||
})
|
||||
|
||||
test('人员批量分配提交最终 REST 请求体', async () => {
|
||||
const request = createRequest()
|
||||
render(<PerformanceSurveyAdminConfig mode="people" request={request} />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('张三')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByLabelText('选择 张三'))
|
||||
fireEvent.change(screen.getByLabelText('批量分配操作'), { target: { value: 'group-1' } })
|
||||
fireEvent.change(screen.getByLabelText('本次调整原因'), { target: { value: '部门异动' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用分配' }))
|
||||
|
||||
await waitFor(() => {
|
||||
const call = request.mock.calls.find(([path, options]) => (
|
||||
path === '/admin/position-assignments' && options?.method === 'POST'
|
||||
))
|
||||
expect(call).toBeDefined()
|
||||
expect(JSON.parse(String(call?.[1]?.body || '{}'))).toEqual({
|
||||
employeeIds: ['employee-1'],
|
||||
effectiveFromMonth: '2026-07',
|
||||
effectiveToMonth: null,
|
||||
assignmentType: 'position',
|
||||
positionGroupId: 'group-1',
|
||||
reason: '部门异动',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('模板发布前要求先保存 dirty 草稿,并提供复制与归档入口', async () => {
|
||||
const request = createRequest()
|
||||
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
|
||||
await waitFor(() => expect(screen.getByText(/v1 · 已发布/)).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制为新草稿' }))
|
||||
await waitFor(() => expect(request).toHaveBeenCalledWith('/admin/templates/version-1/clone', expect.objectContaining({ method: 'POST' })))
|
||||
|
||||
fireEvent.change(screen.getByLabelText('模板名称'), { target: { value: '新视觉评分' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /发布至/ }))
|
||||
await waitFor(() => expect(screen.getByText('岗位规则存在重复匹配人员')).toBeInTheDocument())
|
||||
expect(await screen.findByText('请先保存模板草稿,再执行发布')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '归档模板' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('月度方案可添加排序模板并经过影响确认后发布', async () => {
|
||||
const request = createRequest()
|
||||
render(<PerformanceSurveyAdminConfig mode="plans" request={request} />)
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: '2026-07 月度方案' })).toBeInTheDocument())
|
||||
|
||||
const courseLabel = screen.getByText('课程分享').closest('label')
|
||||
expect(courseLabel).not.toBeNull()
|
||||
fireEvent.click(courseLabel!.querySelector('input')!)
|
||||
fireEvent.click(screen.getByRole('button', { name: '发布前检查' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('region', { name: '发布影响确认' })).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认发布至 2026-07' }))
|
||||
|
||||
await waitFor(() => expect(request).toHaveBeenCalledWith('/admin/position-groups/group-1/publish', expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
effectiveMonth: '2026-07',
|
||||
draftPlanId: 'plan-1',
|
||||
draftVersion: 2,
|
||||
draftUpdatedAt: '2026-07-23T10:00:00.000Z',
|
||||
}),
|
||||
})))
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
430
app/performance-survey/PerformanceSurveyAdminPeople.tsx
Normal file
430
app/performance-survey/PerformanceSurveyAdminPeople.tsx
Normal file
@ -0,0 +1,430 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
export type PerformanceSurveyAdminRequest = <T>(path: string, options?: RequestInit) => Promise<T>
|
||||
|
||||
export type PerformanceSurveyAdminPositionOption = {
|
||||
id: string
|
||||
name: string
|
||||
status: 'ready' | 'pending' | 'disabled'
|
||||
memberCount: number
|
||||
}
|
||||
|
||||
type AssignmentRow = {
|
||||
id: string
|
||||
employeeId?: string
|
||||
displayName: string
|
||||
departmentName?: string | null
|
||||
departmentPath?: string | null
|
||||
positionTitle?: string | null
|
||||
positionGroupId?: string | null
|
||||
positionGroupName?: string | null
|
||||
manualAssignmentId?: string | null
|
||||
assignmentType?: 'position' | 'excluded'
|
||||
participation?: 'assigned' | 'unassigned' | 'excluded'
|
||||
}
|
||||
|
||||
type AssignmentPayload =
|
||||
| AssignmentRow[]
|
||||
| {
|
||||
items?: AssignmentRow[]
|
||||
employees?: AssignmentRow[]
|
||||
total?: number
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
type CreatePositionAssignmentsInput = {
|
||||
employeeIds: string[]
|
||||
positionGroupId: string | null
|
||||
assignmentType: 'position' | 'excluded'
|
||||
effectiveFromMonth: string
|
||||
effectiveToMonth: string | null
|
||||
reason: string
|
||||
}
|
||||
|
||||
const positionAssignmentApi = {
|
||||
list(
|
||||
request: PerformanceSurveyAdminRequest,
|
||||
input: { month: string; search: string; status: string; page: number; pageSize: number },
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
month: input.month,
|
||||
page: String(input.page),
|
||||
pageSize: String(input.pageSize),
|
||||
})
|
||||
if (input.search) params.set('search', input.search)
|
||||
if (input.status !== 'all') params.set('status', input.status)
|
||||
return request<AssignmentPayload>(`/admin/position-assignments?${params.toString()}`)
|
||||
},
|
||||
create(request: PerformanceSurveyAdminRequest, input: CreatePositionAssignmentsInput) {
|
||||
return request('/admin/position-assignments', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
},
|
||||
update(
|
||||
request: PerformanceSurveyAdminRequest,
|
||||
assignmentId: string,
|
||||
input: Omit<CreatePositionAssignmentsInput, 'employeeIds'>,
|
||||
) {
|
||||
return request(`/admin/position-assignments/${assignmentId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
},
|
||||
archive(request: PerformanceSurveyAdminRequest, assignmentId: string, reason: string) {
|
||||
return request(`/admin/position-assignments/${assignmentId}`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ reason }),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
function normalizeAssignmentRows(payload: AssignmentPayload | null): AssignmentRow[] {
|
||||
if (Array.isArray(payload)) return payload
|
||||
if (Array.isArray(payload?.items)) return payload.items
|
||||
if (Array.isArray(payload?.employees)) return payload.employees
|
||||
return []
|
||||
}
|
||||
|
||||
function readAssignmentTotal(payload: AssignmentPayload | null, rows: AssignmentRow[]) {
|
||||
if (Array.isArray(payload)) return payload.length
|
||||
return Number.isFinite(Number(payload?.total)) ? Number(payload?.total) : rows.length
|
||||
}
|
||||
|
||||
function participationLabel(row: AssignmentRow) {
|
||||
if (row.assignmentType === 'excluded' || row.participation === 'excluded') return '不参与考核'
|
||||
if (row.positionGroupName) return row.positionGroupName
|
||||
return '待分配'
|
||||
}
|
||||
|
||||
export function PerformanceSurveyAdminPeople({
|
||||
month,
|
||||
groups,
|
||||
request,
|
||||
onError,
|
||||
onSuccess,
|
||||
}: {
|
||||
month: string
|
||||
groups: PerformanceSurveyAdminPositionOption[]
|
||||
request: PerformanceSurveyAdminRequest
|
||||
onError: (message: string | null) => void
|
||||
onSuccess: (message: string) => void
|
||||
}) {
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'assigned' | 'unassigned' | 'excluded'>('all')
|
||||
const [rows, setRows] = useState<AssignmentRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [assignmentValue, setAssignmentValue] = useState('')
|
||||
const [reason, setReason] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const requestSequence = useRef(0)
|
||||
const pageSize = 200
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const sequence = requestSequence.current + 1
|
||||
requestSequence.current = sequence
|
||||
setLoading(true)
|
||||
setLoadError(null)
|
||||
try {
|
||||
const payload = await positionAssignmentApi.list(request, {
|
||||
month,
|
||||
search: keyword.trim(),
|
||||
status: statusFilter,
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
if (requestSequence.current !== sequence) return
|
||||
const nextRows = normalizeAssignmentRows(payload)
|
||||
setRows(nextRows)
|
||||
setTotal(readAssignmentTotal(payload, nextRows))
|
||||
setSelectedIds(new Set())
|
||||
} catch (error) {
|
||||
if (requestSequence.current !== sequence) return
|
||||
setRows([])
|
||||
setLoadError(error instanceof Error ? error.message : '人员分配加载失败')
|
||||
} finally {
|
||||
if (requestSequence.current === sequence) setLoading(false)
|
||||
}
|
||||
}, [keyword, month, page, request, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [keyword, month, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
return () => {
|
||||
requestSequence.current += 1
|
||||
}
|
||||
}, [load])
|
||||
|
||||
const visibleIds = useMemo(() => rows.map((row) => row.id), [rows])
|
||||
const allVisibleSelected = visibleIds.length > 0 && visibleIds.every((id) => selectedIds.has(id))
|
||||
|
||||
const toggleAll = () => {
|
||||
setSelectedIds((current) => {
|
||||
if (allVisibleSelected) return new Set()
|
||||
return new Set(Array.from(current).concat(visibleIds))
|
||||
})
|
||||
}
|
||||
|
||||
const toggleRow = (id: string) => {
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const applyAssignment = async () => {
|
||||
if (selectedIds.size === 0) {
|
||||
onError('请先选择人员')
|
||||
return
|
||||
}
|
||||
if (!assignmentValue) {
|
||||
onError('请选择要分配的岗位或“不参与考核”')
|
||||
return
|
||||
}
|
||||
if (!reason.trim()) {
|
||||
onError('请输入本次调整原因')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
onError(null)
|
||||
try {
|
||||
const selectedRows = rows.filter((row) => selectedIds.has(row.id))
|
||||
const employeeIds = selectedRows.map((row) => row.employeeId || row.id)
|
||||
if (assignmentValue === '__automatic__') {
|
||||
const manualRows = selectedRows.filter((row) => row.manualAssignmentId)
|
||||
if (manualRows.length === 0) {
|
||||
onError('所选人员当前没有需要移除的手动分配')
|
||||
return
|
||||
}
|
||||
await Promise.all(manualRows.map((row) => (
|
||||
positionAssignmentApi.archive(request, row.manualAssignmentId!, reason.trim())
|
||||
)))
|
||||
onSuccess(`已将 ${manualRows.length} 人恢复为按岗位规则自动匹配`)
|
||||
setReason('')
|
||||
await load()
|
||||
return
|
||||
}
|
||||
const assignmentInput = {
|
||||
effectiveFromMonth: month,
|
||||
effectiveToMonth: null,
|
||||
assignmentType: assignmentValue === '__excluded__' ? 'excluded' : 'position',
|
||||
positionGroupId: assignmentValue === '__excluded__' ? null : assignmentValue,
|
||||
reason: reason.trim(),
|
||||
} as const
|
||||
const rowsWithManualAssignment = selectedRows.filter((row) => row.manualAssignmentId)
|
||||
const newEmployeeIds = selectedRows
|
||||
.filter((row) => !row.manualAssignmentId)
|
||||
.map((row) => row.employeeId || row.id)
|
||||
await Promise.all([
|
||||
...(newEmployeeIds.length > 0
|
||||
? [positionAssignmentApi.create(request, { ...assignmentInput, employeeIds: newEmployeeIds })]
|
||||
: []),
|
||||
...rowsWithManualAssignment.map((row) => (
|
||||
positionAssignmentApi.update(request, row.manualAssignmentId!, assignmentInput)
|
||||
)),
|
||||
])
|
||||
onSuccess(
|
||||
assignmentValue === '__excluded__'
|
||||
? `已将 ${employeeIds.length} 人设为不参与考核`
|
||||
: `已为 ${employeeIds.length} 人分配岗位`,
|
||||
)
|
||||
setReason('')
|
||||
await load()
|
||||
} catch (error) {
|
||||
onError(error instanceof Error ? error.message : '批量分配失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="performance-survey-people-title">
|
||||
<div className="border-b border-[#e2e2e2] pb-4">
|
||||
<h2 id="performance-survey-people-title" className="text-[18px] font-bold text-[#1a1c1c]">人员分配</h2>
|
||||
<p className="mt-1 max-w-3xl text-[13px] leading-5 text-[#5f5e5e]">
|
||||
按 {month} 维护人员所属考核岗位;人员异动后可重新分配,或明确标记为不参与考核。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 border-b border-[#e2e2e2] py-4 md:grid-cols-[minmax(220px,1fr)_180px_auto]">
|
||||
<label className="grid gap-1 text-[12px] font-semibold text-[#5f5e5e]">
|
||||
搜索人员
|
||||
<input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') void load()
|
||||
}}
|
||||
placeholder="姓名、部门或职位"
|
||||
className="h-11 rounded border border-[#c2c6d8] px-3 text-[13px] text-[#1a1c1c] outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dbe6ff]"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-[12px] font-semibold text-[#5f5e5e]">
|
||||
分配状态
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value as typeof statusFilter)}
|
||||
className="h-11 rounded border border-[#c2c6d8] bg-white px-3 text-[13px] text-[#1a1c1c] outline-none focus:border-[#0050cb]"
|
||||
>
|
||||
<option value="all">全部人员</option>
|
||||
<option value="assigned">已分配</option>
|
||||
<option value="unassigned">待分配</option>
|
||||
<option value="excluded">不参与考核</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void load()}
|
||||
className="min-h-11 self-end rounded border border-[#1a1c1c] px-4 text-[13px] font-bold text-[#1a1c1c] hover:bg-[#f4f3f3] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb]"
|
||||
>
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadError && (
|
||||
<div role="alert" className="my-4 flex flex-wrap items-center justify-between gap-3 border border-[#f0b8b8] bg-[#fff8f7] px-4 py-3 text-[13px] text-[#93000a]">
|
||||
<span>{loadError}</span>
|
||||
<button type="button" onClick={() => void load()} className="min-h-9 px-2 font-bold underline">重试</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 overflow-x-auto border border-[#c2c6d8]">
|
||||
<table className="w-full min-w-[760px] border-collapse text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-[#c2c6d8] bg-[#f4f3f3] text-[12px] font-bold text-[#5f5e5e]">
|
||||
<th scope="col" className="w-12 px-3 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allVisibleSelected}
|
||||
onChange={toggleAll}
|
||||
aria-label="选择当前列表全部人员"
|
||||
/>
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-3">人员</th>
|
||||
<th scope="col" className="px-3 py-3">部门</th>
|
||||
<th scope="col" className="px-3 py-3">职位</th>
|
||||
<th scope="col" className="w-[180px] px-3 py-3">当前分配</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#e2e2e2]">
|
||||
{loading && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-10 text-center text-[13px] font-semibold text-[#5f5e5e]">人员加载中...</td>
|
||||
</tr>
|
||||
)}
|
||||
{!loading && rows.map((row) => (
|
||||
<tr key={row.id} className={selectedIds.has(row.id) ? 'bg-[#f4f7ff]' : 'bg-white hover:bg-[#fcfcfc]'}>
|
||||
<td className="px-3 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(row.id)}
|
||||
onChange={() => toggleRow(row.id)}
|
||||
aria-label={`选择 ${row.displayName}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-[13px] font-bold text-[#1a1c1c]">{row.displayName}</td>
|
||||
<td className="px-3 py-3 text-[13px] text-[#5f5e5e]">{row.departmentPath || row.departmentName || '-'}</td>
|
||||
<td className="px-3 py-3 text-[13px] text-[#5f5e5e]">{row.positionTitle || '-'}</td>
|
||||
<td className="px-3 py-3">
|
||||
<span className={`inline-flex rounded px-2 py-1 text-[12px] font-semibold ${
|
||||
row.participation === 'excluded'
|
||||
? 'bg-[#f4f3f3] text-[#5f5e5e]'
|
||||
: row.positionGroupName
|
||||
? 'bg-[#eef5ff] text-[#0050cb]'
|
||||
: 'bg-[#fff4df] text-[#8a5700]'
|
||||
}`}>
|
||||
{participationLabel(row)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && rows.length === 0 && !loadError && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-10 text-center text-[13px] font-semibold text-[#5f5e5e]">
|
||||
当前条件下没有人员
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-x border-b border-[#c2c6d8] px-3 py-3 text-[12px] font-semibold text-[#5f5e5e]">
|
||||
<span>
|
||||
共 {total} 人
|
||||
{total > 0 ? ` · 当前显示 ${(page - 1) * pageSize + 1}–${Math.min(page * pageSize, total)}` : ''}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page <= 1 || loading}
|
||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||
className="min-h-9 rounded border border-[#c2c6d8] px-3 font-bold text-[#1a1c1c] disabled:opacity-40"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span>第 {page} / {Math.max(1, Math.ceil(total / pageSize))} 页</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page * pageSize >= total || loading}
|
||||
onClick={() => setPage((current) => current + 1)}
|
||||
className="min-h-9 rounded border border-[#c2c6d8] px-3 font-bold text-[#1a1c1c] disabled:opacity-40"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-[68px] z-20 -mx-4 mt-4 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:flex sm:items-center sm:justify-between sm:shadow-none">
|
||||
<div className="mb-2 text-[12px] font-semibold text-[#5f5e5e] sm:mb-0">
|
||||
已选择 {selectedIds.size} 人
|
||||
</div>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2 sm:flex">
|
||||
<div className="grid min-w-0 gap-2 sm:grid-cols-[220px_240px]">
|
||||
<select
|
||||
aria-label="批量分配操作"
|
||||
value={assignmentValue}
|
||||
onChange={(event) => setAssignmentValue(event.target.value)}
|
||||
className="h-11 min-w-0 rounded border border-[#c2c6d8] bg-white px-3 text-[13px] font-semibold text-[#1a1c1c] outline-none focus:border-[#0050cb]"
|
||||
>
|
||||
<option value="">选择分配结果</option>
|
||||
{groups.filter((group) => group.status !== 'disabled').map((group) => (
|
||||
<option key={group.id} value={group.id}>{group.name}</option>
|
||||
))}
|
||||
<option value="__excluded__">不参与考核</option>
|
||||
<option value="__automatic__">恢复自动匹配</option>
|
||||
</select>
|
||||
<input
|
||||
aria-label="本次调整原因"
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder="本次调整原因(必填)"
|
||||
className="h-11 min-w-0 rounded border border-[#c2c6d8] px-3 text-[13px] text-[#1a1c1c] outline-none focus:border-[#0050cb]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || selectedIds.size === 0}
|
||||
onClick={() => void applyAssignment()}
|
||||
className="min-h-11 rounded bg-[#0050cb] px-4 text-[13px] font-bold text-white hover:bg-[#003fa4] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saving ? '处理中...' : '应用分配'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -758,14 +758,25 @@ function Dashboard({ me, permissionsLoading }: { me: any | null; permissionsLoad
|
||||
query.set('tab', nextTab)
|
||||
const workspaceTab = getLegacyPerformanceSurveyWorkspaceTab(searchParams.get('workspace'))
|
||||
if (workspaceTab) query.set('workspace', workspaceTab)
|
||||
const adminWorkspace = searchParams.get('adminWorkspace')
|
||||
if (adminWorkspace) query.set('adminWorkspace', adminWorkspace)
|
||||
router.replace(`/performance-survey?${query.toString()}`, { scroll: false })
|
||||
}
|
||||
const tabItems: Array<{ id: PerformanceSurveyDashboardTab; label: string }> = [
|
||||
{ id: 'personal', label: '个人看板' },
|
||||
{ id: 'public', label: '公共看板' },
|
||||
...(canCreateSurvey ? [{ id: 'manage' as const, label: '发起调研' }] : []),
|
||||
...(canCreateSurvey
|
||||
? [{
|
||||
id: 'manage' as const,
|
||||
label: me?.permissions?.isSystemAdmin ? '调研管理' : '发起调研',
|
||||
}]
|
||||
: []),
|
||||
]
|
||||
const pageTitle = tab === 'public' ? '公共看板' : tab === 'manage' ? '发起调研' : '个人绩效概览'
|
||||
const pageTitle = tab === 'public'
|
||||
? '公共看板'
|
||||
: tab === 'manage'
|
||||
? me?.permissions?.isSystemAdmin ? '考核调研管理' : '发起调研'
|
||||
: '个人绩效概览'
|
||||
const pageSubtitle = tab === 'personal'
|
||||
? '过去 12 个月的考核详细数据'
|
||||
: tab === 'public'
|
||||
@ -2743,6 +2754,25 @@ function DepartmentFilterTree({
|
||||
)
|
||||
}
|
||||
|
||||
type PerformanceSurveyAdminWorkspace =
|
||||
| 'overview'
|
||||
| 'launch'
|
||||
| 'positions'
|
||||
| 'people'
|
||||
| 'templates'
|
||||
| 'plans'
|
||||
| 'operations'
|
||||
|
||||
const PERFORMANCE_SURVEY_ADMIN_WORKSPACES: PerformanceSurveyAdminWorkspace[] = [
|
||||
'overview',
|
||||
'launch',
|
||||
'positions',
|
||||
'people',
|
||||
'templates',
|
||||
'plans',
|
||||
'operations',
|
||||
]
|
||||
|
||||
function ManagePanel({
|
||||
currentEmployee,
|
||||
isSystemAdmin,
|
||||
@ -2752,30 +2782,97 @@ function ManagePanel({
|
||||
isSystemAdmin: boolean
|
||||
canSelectAnyPosition: boolean
|
||||
}) {
|
||||
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 [workspace, setWorkspace] = useState<'launch' | 'positions' | 'templates'>('launch')
|
||||
const requestedWorkspace = String(searchParams.get('adminWorkspace') || '').trim() as PerformanceSurveyAdminWorkspace
|
||||
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],
|
||||
)
|
||||
const notificationStatus = useApi<any>(
|
||||
isSystemAdmin && workspace === 'operations' ? '/admin/notifications/status' : null,
|
||||
[workspace, isSystemAdmin],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSystemAdmin) {
|
||||
if (workspace !== 'launch') setWorkspace('launch')
|
||||
return
|
||||
}
|
||||
if (PERFORMANCE_SURVEY_ADMIN_WORKSPACES.includes(requestedWorkspace) && requestedWorkspace !== workspace) {
|
||||
if (
|
||||
configurationDirty
|
||||
&& typeof window !== 'undefined'
|
||||
&& !window.confirm('当前配置尚未保存,确定离开吗?')
|
||||
) {
|
||||
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)
|
||||
setWorkspace(requestedWorkspace)
|
||||
}
|
||||
}, [configurationDirty, isSystemAdmin, requestedWorkspace, router, searchParams, workspace])
|
||||
|
||||
const selectWorkspace = (nextWorkspace: PerformanceSurveyAdminWorkspace) => {
|
||||
if (
|
||||
nextWorkspace !== workspace
|
||||
&& configurationDirty
|
||||
&& typeof window !== 'undefined'
|
||||
&& !window.confirm('当前配置尚未保存,确定离开吗?')
|
||||
) {
|
||||
return
|
||||
}
|
||||
setMessage(null)
|
||||
setConfigurationDirty(false)
|
||||
setWorkspace(nextWorkspace)
|
||||
const query = new URLSearchParams(searchParams.toString())
|
||||
query.set('tab', 'manage')
|
||||
query.set('adminWorkspace', nextWorkspace)
|
||||
router.replace(`/performance-survey?${query.toString()}`, { scroll: false })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{isSystemAdmin && (
|
||||
<div className="flex h-10 w-fit items-center border border-[#e2e2e2] bg-white p-1">
|
||||
<nav
|
||||
aria-label="考核调研管理功能"
|
||||
className="flex max-w-full items-center gap-1 overflow-x-auto border-b border-[#e2e2e2] bg-white pb-2"
|
||||
>
|
||||
{([
|
||||
['overview', '管理概览'],
|
||||
['launch', '发起调研'],
|
||||
['positions', '岗位配置'],
|
||||
['templates', '模板管理'],
|
||||
['positions', '岗位'],
|
||||
['people', '人员分配'],
|
||||
['templates', '模板'],
|
||||
['plans', '月度方案'],
|
||||
['operations', '运行维护'],
|
||||
] as const).map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setWorkspace(id)}
|
||||
className={`h-8 px-4 text-[12px] font-bold ${workspace === id ? 'bg-[#1a1c1c] text-white' : 'text-[#5f5e5e] hover:bg-[#f7f7f7]'}`}
|
||||
onClick={() => selectWorkspace(id)}
|
||||
aria-current={workspace === id ? 'page' : undefined}
|
||||
className={`h-9 shrink-0 rounded px-3 text-[12px] font-bold transition-colors ${
|
||||
workspace === id
|
||||
? 'bg-[#1a1c1c] text-white'
|
||||
: 'text-[#5f5e5e] hover:bg-[#f4f4f4] hover:text-[#1a1c1c]'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
{workspace === 'launch' ? (
|
||||
<CreateSurveyForm
|
||||
@ -2787,10 +2884,22 @@ function ManagePanel({
|
||||
await surveys.refresh()
|
||||
}}
|
||||
/>
|
||||
) : workspace === 'operations' ? (
|
||||
<section className="border border-[#e2e2e2] bg-white" aria-labelledby="performance-survey-operations-title">
|
||||
<div className="border-b border-[#e2e2e2] px-4 py-4">
|
||||
<h2 id="performance-survey-operations-title" className="text-[18px] font-bold text-[#1a1c1c]">运行维护</h2>
|
||||
<p className="mt-1 text-[12px] font-medium text-[#727687]">同步人员档案、检查通知异常并补发消息</p>
|
||||
</div>
|
||||
<div className="grid gap-4 p-4 lg:grid-cols-[minmax(0,320px)_minmax(0,1fr)]">
|
||||
<AdminActions onMessage={setMessage} />
|
||||
<NotificationList state={notifications} statusState={notificationStatus} />
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<PerformanceSurveyAdminConfig
|
||||
mode={workspace}
|
||||
request={apiRequest}
|
||||
onDirtyChange={setConfigurationDirty}
|
||||
/>
|
||||
)}
|
||||
{message && (
|
||||
@ -4034,19 +4143,20 @@ function AdminActions({
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-[14px] bg-white px-4 py-4 shadow-[0_10px_24px_rgba(45,43,80,0.07)] dark:border dark:border-gray-700 dark:bg-gray-900">
|
||||
<h2 className="text-[18px] font-semibold text-gray-900 dark:text-white">维护</h2>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<section className="border border-[#e2e2e2] bg-white px-4 py-4">
|
||||
<h3 className="text-[16px] font-bold text-[#1a1c1c]">数据维护</h3>
|
||||
<p className="mt-1 text-[12px] font-medium text-[#727687]">人员同步会更新本地花名册,不会修改历史调研。</p>
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
disabled={busy !== null}
|
||||
onClick={() => run('sync', '/admin/sync-employees', '人员档案已同步')}
|
||||
className="rounded-[10px] border border-[#dbe5f2] px-2 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
className="min-h-10 rounded border border-[#c9c9c9] px-3 py-2 text-sm font-bold text-[#1a1c1c] hover:border-[#1a1c1c] hover:bg-[#f7f7f7] disabled:opacity-50"
|
||||
>
|
||||
{busy === 'sync' ? '同步中' : '人员同步'}
|
||||
</button>
|
||||
<a
|
||||
href="/api/performance-survey/admin/export"
|
||||
className="rounded-[10px] border border-[#dbe5f2] px-2 py-2 text-center text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
className="flex min-h-10 items-center justify-center rounded border border-[#c9c9c9] px-3 py-2 text-center text-sm font-bold text-[#1a1c1c] hover:border-[#1a1c1c] hover:bg-[#f7f7f7]"
|
||||
>
|
||||
导出数据
|
||||
</a>
|
||||
@ -4092,17 +4202,17 @@ function NotificationList({
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-[14px] bg-white px-4 py-4 shadow-[0_10px_24px_rgba(45,43,80,0.07)] dark:border dark:border-gray-700 dark:bg-gray-900">
|
||||
<section className="border border-[#e2e2e2] bg-white px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-[18px] font-semibold text-gray-900 dark:text-white">钉钉消息</h2>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{total} 条 · {sentCount} 已发送 · {failedCount} 异常</div>
|
||||
<h3 className="text-[16px] font-bold text-[#1a1c1c]">钉钉消息</h3>
|
||||
<div className="mt-1 text-xs font-medium text-[#727687]">{total} 条 · {sentCount} 已发送 · {failedCount} 异常</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await Promise.all([state.refresh(), statusState.refresh()])
|
||||
}}
|
||||
className="rounded-[10px] border border-[#dbe5f2] px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200"
|
||||
className="min-h-9 rounded px-3 py-1.5 text-sm font-bold text-[#0050cb] hover:bg-[#f4f7ff]"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
@ -4119,23 +4229,23 @@ function NotificationList({
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 divide-y divide-[#eef1f6] dark:divide-gray-800">
|
||||
{state.loading && <div className="text-sm text-gray-500">加载中...</div>}
|
||||
{state.error && <div className="text-sm text-red-600">{state.error}</div>}
|
||||
{records.length === 0 && !state.loading && <div className="py-3 text-sm text-gray-500">暂无消息记录</div>}
|
||||
{state.loading && <div className="text-sm text-[#727687]">加载中...</div>}
|
||||
{state.error && <div className="text-sm font-semibold text-[#93000a]">{state.error}</div>}
|
||||
{records.length === 0 && !state.loading && <div className="py-3 text-sm text-[#727687]">暂无消息记录</div>}
|
||||
{visibleRecords.map((item) => {
|
||||
const isRetryable = ['retrying', 'exhausted', 'skipped'].includes(item.status)
|
||||
return (
|
||||
<div key={item.id} className="py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">{item.title}</div>
|
||||
<div className="mt-1 truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="truncate text-sm font-bold text-[#1a1c1c]">{item.title}</div>
|
||||
<div className="mt-1 truncate text-xs text-[#727687]">
|
||||
{item.recipientName || '收件人缺失'} · {getNotificationEventLabel(item.eventType)}
|
||||
</div>
|
||||
{item.lastError && (
|
||||
<details className="mt-1 hidden text-xs text-gray-500 dark:text-gray-400 sm:block">
|
||||
<summary className="cursor-pointer list-none text-red-500">详情</summary>
|
||||
<div className="mt-1 break-words text-red-500">{item.lastError}</div>
|
||||
<details className="mt-1 hidden text-xs text-[#727687] sm:block">
|
||||
<summary className="cursor-pointer list-none font-semibold text-[#93000a]">详情</summary>
|
||||
<div className="mt-1 break-words text-[#93000a]">{item.lastError}</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
@ -4145,7 +4255,7 @@ function NotificationList({
|
||||
<button
|
||||
disabled={busyId === item.id}
|
||||
onClick={() => retry(item.id)}
|
||||
className="rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
className="rounded border border-[#c9c9c9] px-2 py-1 text-xs font-bold text-[#1a1c1c] hover:border-[#1a1c1c] hover:bg-[#f7f7f7] disabled:opacity-50"
|
||||
>
|
||||
补发
|
||||
</button>
|
||||
|
||||
151
lib/__tests__/performance-survey-position-admin.test.ts
Normal file
151
lib/__tests__/performance-survey-position-admin.test.ts
Normal file
@ -0,0 +1,151 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
assertPerformanceSurveyPositionPlanPreviewMatches,
|
||||
savePerformanceSurveyPositionGroupDraft,
|
||||
} from '@/lib/performance-survey-position-config'
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
performanceSurveyPositionGroup: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
performanceSurveyPositionRule: {
|
||||
deleteMany: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
performanceSurveyTemplateVersion: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('performance survey position admin', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
test('保存岗位组草稿只更新方案草稿,不改线上岗位组和匹配规则', async () => {
|
||||
const liveGroup = {
|
||||
id: 'group-1',
|
||||
code: 'brand_designer',
|
||||
name: '线上岗位名',
|
||||
sortOrder: 10,
|
||||
isActive: true,
|
||||
isSystem: true,
|
||||
archivedAt: null,
|
||||
}
|
||||
const existingDraft = {
|
||||
id: 'draft-1',
|
||||
positionGroupId: liveGroup.id,
|
||||
version: 2,
|
||||
status: 'draft',
|
||||
effectiveMonth: '2026-08',
|
||||
matchRules: [{ positionTitle: '线上规则' }],
|
||||
groupConfig: {
|
||||
name: liveGroup.name,
|
||||
sortOrder: liveGroup.sortOrder,
|
||||
isActive: liveGroup.isActive,
|
||||
},
|
||||
items: [],
|
||||
}
|
||||
const updatedDraft = {
|
||||
...existingDraft,
|
||||
matchRules: [{ positionTitle: '草稿规则' }],
|
||||
groupConfig: {
|
||||
name: '草稿岗位名',
|
||||
sortOrder: 20,
|
||||
isActive: false,
|
||||
},
|
||||
}
|
||||
const tx = {
|
||||
performanceSurveyPositionPlan: {
|
||||
findFirst: jest.fn().mockResolvedValue(existingDraft),
|
||||
update: jest.fn().mockResolvedValue(updatedDraft),
|
||||
findUnique: jest.fn().mockResolvedValue(updatedDraft),
|
||||
},
|
||||
performanceSurveyPositionPlanItem: {
|
||||
deleteMany: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
},
|
||||
performanceSurveyPositionGroup: {
|
||||
findUnique: jest.fn().mockResolvedValue(liveGroup),
|
||||
update: jest.fn(),
|
||||
},
|
||||
performanceSurveyPositionRule: {
|
||||
deleteMany: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
performanceSurveyConfigAuditLog: {
|
||||
create: jest.fn().mockResolvedValue({ id: 'audit-1' }),
|
||||
},
|
||||
$queryRawUnsafe: jest.fn().mockResolvedValue([{ locked: '1' }]),
|
||||
}
|
||||
|
||||
;(prisma.performanceSurveyPositionGroup.findUnique as jest.Mock).mockResolvedValue(liveGroup)
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx))
|
||||
|
||||
await expect(savePerformanceSurveyPositionGroupDraft({
|
||||
groupId: liveGroup.id,
|
||||
name: '草稿岗位名',
|
||||
sortOrder: 20,
|
||||
isActive: false,
|
||||
rules: [{ positionTitle: '草稿规则' }],
|
||||
effectiveMonth: '2026-08',
|
||||
reason: '配置调整',
|
||||
actorUserId: 'admin-1',
|
||||
actorName: '管理员',
|
||||
})).resolves.toMatchObject({
|
||||
groupId: liveGroup.id,
|
||||
draftPlanId: existingDraft.id,
|
||||
version: existingDraft.version,
|
||||
})
|
||||
|
||||
expect(tx.performanceSurveyPositionPlan.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: existingDraft.id },
|
||||
data: expect.objectContaining({
|
||||
matchRules: [{ positionTitle: '草稿规则', departmentPathPrefix: null }],
|
||||
groupConfig: {
|
||||
name: '草稿岗位名',
|
||||
sortOrder: 20,
|
||||
isActive: false,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
expect(tx.performanceSurveyConfigAuditLog.create).toHaveBeenCalledTimes(1)
|
||||
expect(prisma.performanceSurveyPositionGroup.update).not.toHaveBeenCalled()
|
||||
expect(prisma.performanceSurveyPositionRule.deleteMany).not.toHaveBeenCalled()
|
||||
expect(prisma.performanceSurveyPositionRule.createMany).not.toHaveBeenCalled()
|
||||
expect(tx.performanceSurveyPositionGroup.update).not.toHaveBeenCalled()
|
||||
expect(tx.performanceSurveyPositionRule.deleteMany).not.toHaveBeenCalled()
|
||||
expect(tx.performanceSurveyPositionRule.createMany).not.toHaveBeenCalled()
|
||||
expect(tx.$queryRawUnsafe).toHaveBeenCalledWith(
|
||||
expect.stringContaining('pg_advisory_xact_lock'),
|
||||
`performance-survey-position-group:${liveGroup.id}`,
|
||||
)
|
||||
})
|
||||
|
||||
test('发布只能使用发布前检查确认过的同一份草稿', () => {
|
||||
const draft = {
|
||||
id: 'draft-1',
|
||||
version: 3,
|
||||
updatedAt: new Date('2026-07-23T10:00:00.000Z'),
|
||||
}
|
||||
expect(() => assertPerformanceSurveyPositionPlanPreviewMatches(draft, {
|
||||
draftPlanId: draft.id,
|
||||
draftVersion: draft.version,
|
||||
draftUpdatedAt: '2026-07-23T10:00:00.000Z',
|
||||
})).not.toThrow()
|
||||
|
||||
expect(() => assertPerformanceSurveyPositionPlanPreviewMatches(draft, {
|
||||
draftPlanId: draft.id,
|
||||
draftVersion: draft.version,
|
||||
draftUpdatedAt: '2026-07-23T10:00:01.000Z',
|
||||
})).toThrow('岗位方案草稿已发生变化,请重新执行发布前检查')
|
||||
})
|
||||
})
|
||||
@ -4,7 +4,9 @@ import {
|
||||
PERFORMANCE_SURVEY_POSITION_GROUP_SEEDS,
|
||||
matchesPerformanceSurveyPositionRule,
|
||||
paginatePerformanceSurveyTemplates,
|
||||
performanceSurveyMonthRangesOverlap,
|
||||
resolvePerformanceSurveyPositionMembership,
|
||||
validatePerformanceSurveyPositionPlanTemplateVersions,
|
||||
validatePerformanceSurveyTemplateDefinition,
|
||||
type PositionGroupRuntime,
|
||||
} from '@/lib/performance-survey-position-config'
|
||||
@ -14,6 +16,8 @@ function group(input: Partial<PositionGroupRuntime> & Pick<PositionGroupRuntime,
|
||||
return {
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
archivedAt: null,
|
||||
status: 'ready',
|
||||
planId: 'plan-1',
|
||||
planVersion: 1,
|
||||
@ -78,6 +82,74 @@ describe('performance survey position configuration', () => {
|
||||
}, [creative])).toMatchObject({ status: 'unmatched', group: null })
|
||||
})
|
||||
|
||||
test('人工岗位或排除配置优先于自动岗位规则', () => {
|
||||
const automatic = group({
|
||||
id: 'automatic',
|
||||
code: 'automatic',
|
||||
name: '自动岗位',
|
||||
rules: [{ positionTitle: 'AI策划' }],
|
||||
})
|
||||
const manual = group({
|
||||
id: 'manual',
|
||||
code: 'manual',
|
||||
name: '人工岗位',
|
||||
rules: [],
|
||||
})
|
||||
const employee = { positionTitle: 'AI策划' }
|
||||
|
||||
expect(resolvePerformanceSurveyPositionMembership(employee, [automatic, manual], {
|
||||
id: 'assignment-position',
|
||||
assignmentType: 'position',
|
||||
positionGroupId: manual.id,
|
||||
})).toMatchObject({
|
||||
status: 'matched',
|
||||
group: { id: manual.id },
|
||||
source: 'manual',
|
||||
manualAssignmentId: 'assignment-position',
|
||||
})
|
||||
|
||||
expect(resolvePerformanceSurveyPositionMembership(employee, [automatic, manual], {
|
||||
id: 'assignment-excluded',
|
||||
assignmentType: 'excluded',
|
||||
})).toEqual({
|
||||
status: 'excluded',
|
||||
group: null,
|
||||
matches: [],
|
||||
source: 'manual',
|
||||
manualAssignmentId: 'assignment-excluded',
|
||||
})
|
||||
})
|
||||
|
||||
test('人工岗位月份区间按闭区间判断重叠', () => {
|
||||
expect(performanceSurveyMonthRangesOverlap(
|
||||
{ startMonth: '2026-01', endMonth: '2026-03' },
|
||||
{ startMonth: '2026-03', endMonth: '2026-05' },
|
||||
)).toBe(true)
|
||||
expect(performanceSurveyMonthRangesOverlap(
|
||||
{ startMonth: '2026-01', endMonth: '2026-02' },
|
||||
{ startMonth: '2026-03', endMonth: null },
|
||||
)).toBe(false)
|
||||
expect(performanceSurveyMonthRangesOverlap(
|
||||
{ startMonth: '2026-03', endMonth: null },
|
||||
{ startMonth: '2099-01', endMonth: '2099-12' },
|
||||
)).toBe(true)
|
||||
})
|
||||
|
||||
test('岗位方案拒绝重复模板、草稿版本和晚于方案生效月的模板版本', () => {
|
||||
expect(() => validatePerformanceSurveyPositionPlanTemplateVersions([
|
||||
{ id: 'version-1', templateId: 'template-1', status: 'published', effectiveMonth: '2026-01' },
|
||||
{ id: 'version-2', templateId: 'template-1', status: 'published', effectiveMonth: '2026-02' },
|
||||
], '2026-03')).toThrow('同一模板只能选择一个版本')
|
||||
|
||||
expect(() => validatePerformanceSurveyPositionPlanTemplateVersions([
|
||||
{ id: 'version-1', templateId: 'template-1', status: 'draft', effectiveMonth: '2026-01' },
|
||||
], '2026-03')).toThrow('只能选择已发布模板版本')
|
||||
|
||||
expect(() => validatePerformanceSurveyPositionPlanTemplateVersions([
|
||||
{ id: 'version-1', templateId: 'template-1', status: 'published', effectiveMonth: '2026-04' },
|
||||
], '2026-03')).toThrow('生效月份不能晚于岗位方案生效月份')
|
||||
})
|
||||
|
||||
test('看板考核项固定每页最多四项并钳制越界页码', () => {
|
||||
const items = Array.from({ length: 9 }, (_, index) => `item-${index + 1}`)
|
||||
expect(paginatePerformanceSurveyTemplates(items, 1)).toEqual({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -24,19 +24,29 @@ import {
|
||||
} from '@/lib/performance-survey-templates'
|
||||
import {
|
||||
PerformanceSurveyPositionConfigError,
|
||||
archivePerformanceSurveyManualPositionAssignment,
|
||||
copyPerformanceSurveyPositionGroup,
|
||||
createPerformanceSurveyManualPositionAssignments,
|
||||
createPerformanceSurveyPositionGroup,
|
||||
createPerformanceSurveyTemplateDraftFromVersion,
|
||||
getManualReviewerStages,
|
||||
getPerformanceSurveyPositionAdminConfig,
|
||||
getPerformanceSurveyPositionGroupByCode,
|
||||
getPerformanceSurveyPositionGroupForEmployee,
|
||||
getPerformanceSurveyPositionMembershipsForEmployees,
|
||||
listPerformanceSurveyConfigAuditLogs,
|
||||
listPerformanceSurveyManualPositionAssignments,
|
||||
listPerformanceSurveyPositionGroups,
|
||||
normalizePerformanceSurveyAssessmentMonth,
|
||||
paginatePerformanceSurveyTemplates,
|
||||
publishPerformanceSurveyPositionPlan,
|
||||
publishPerformanceSurveyTemplateVersion,
|
||||
resolvePerformanceSurveyPositionMembership,
|
||||
previewPerformanceSurveyPositionPlanImpact,
|
||||
removePerformanceSurveyPositionGroup,
|
||||
removePerformanceSurveyTemplate,
|
||||
savePerformanceSurveyPositionGroupDraft,
|
||||
savePerformanceSurveyTemplateDraft,
|
||||
updatePerformanceSurveyManualPositionAssignment,
|
||||
} from '@/lib/performance-survey-position-config'
|
||||
import {
|
||||
ensurePerformanceSurveyNotificationWorkerStarted,
|
||||
@ -1007,8 +1017,8 @@ export async function getPerformanceSurveyPositionGroups(session: Session | null
|
||||
const month = normalizeAssessmentMonth(params?.get('month')) || getCurrentMonthRange().value
|
||||
const groups = await listPerformanceSurveyPositionGroups(month)
|
||||
const membership = ctx.employee
|
||||
? resolvePerformanceSurveyPositionMembership(ctx.employee, groups)
|
||||
: { status: 'unmatched' as const, group: null, matches: [] }
|
||||
? await getPerformanceSurveyPositionGroupForEmployee(ctx.employee, month)
|
||||
: { status: 'unmatched' as const, group: null, matches: [], source: 'automatic' as const, manualAssignmentId: null }
|
||||
return {
|
||||
month,
|
||||
groups: groups.map((group) => ({
|
||||
@ -1068,6 +1078,8 @@ export async function savePerformanceSurveyAdminPositionGroup(
|
||||
isActive: body?.isActive,
|
||||
rules: body?.rules,
|
||||
templateVersionIds: body?.templateVersionIds,
|
||||
effectiveMonth: body?.effectiveMonth,
|
||||
reason: body?.reason,
|
||||
actorUserId: ctx.user.id,
|
||||
actorName: getDisplayName(ctx.user),
|
||||
})
|
||||
@ -1087,6 +1099,10 @@ export async function publishPerformanceSurveyAdminPositionGroup(
|
||||
return await publishPerformanceSurveyPositionPlan({
|
||||
groupId,
|
||||
effectiveMonth: clean(body?.effectiveMonth) || '',
|
||||
draftPlanId: clean(body?.draftPlanId),
|
||||
draftVersion: body?.draftVersion,
|
||||
draftUpdatedAt: body?.draftUpdatedAt,
|
||||
reason: body?.reason,
|
||||
actorUserId: ctx.user.id,
|
||||
actorName: getDisplayName(ctx.user),
|
||||
})
|
||||
@ -1102,6 +1118,7 @@ export async function savePerformanceSurveyAdminTemplate(session: Session | null
|
||||
return await savePerformanceSurveyTemplateDraft({
|
||||
versionId: clean(body?.versionId),
|
||||
definition: body?.definition,
|
||||
reason: body?.reason,
|
||||
actorUserId: ctx.user.id,
|
||||
actorName: getDisplayName(ctx.user),
|
||||
})
|
||||
@ -1119,6 +1136,7 @@ export async function clonePerformanceSurveyAdminTemplateVersion(
|
||||
try {
|
||||
return await createPerformanceSurveyTemplateDraftFromVersion({
|
||||
versionId,
|
||||
reason: null,
|
||||
actorUserId: ctx.user.id,
|
||||
actorName: getDisplayName(ctx.user),
|
||||
})
|
||||
@ -1138,6 +1156,7 @@ export async function publishPerformanceSurveyAdminTemplateVersion(
|
||||
return await publishPerformanceSurveyTemplateVersion({
|
||||
versionId,
|
||||
effectiveMonth: clean(body?.effectiveMonth) || '',
|
||||
reason: body?.reason,
|
||||
actorUserId: ctx.user.id,
|
||||
actorName: getDisplayName(ctx.user),
|
||||
})
|
||||
@ -1146,6 +1165,189 @@ export async function publishPerformanceSurveyAdminTemplateVersion(
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPerformanceSurveyAdminPositionGroup(session: Session | null, body: any) {
|
||||
const ctx = await getPerformanceSurveyContext(session)
|
||||
ensureSystemAdministrator(ctx)
|
||||
try {
|
||||
return await createPerformanceSurveyPositionGroup({
|
||||
code: clean(body?.code) || '',
|
||||
name: clean(body?.name) || '',
|
||||
sortOrder: body?.sortOrder,
|
||||
isActive: body?.isActive,
|
||||
rules: body?.rules,
|
||||
templateVersionIds: body?.templateVersionIds,
|
||||
effectiveMonth: body?.effectiveMonth,
|
||||
reason: body?.reason,
|
||||
actorUserId: ctx.user.id,
|
||||
actorName: getDisplayName(ctx.user),
|
||||
})
|
||||
} catch (error) {
|
||||
return rethrowPositionConfigError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyPerformanceSurveyAdminPositionGroup(
|
||||
session: Session | null,
|
||||
groupId: string,
|
||||
body: any,
|
||||
) {
|
||||
const ctx = await getPerformanceSurveyContext(session)
|
||||
ensureSystemAdministrator(ctx)
|
||||
try {
|
||||
return await copyPerformanceSurveyPositionGroup({
|
||||
sourceGroupId: groupId,
|
||||
code: clean(body?.code) || '',
|
||||
name: clean(body?.name) || '',
|
||||
month: body?.month,
|
||||
reason: body?.reason,
|
||||
actorUserId: ctx.user.id,
|
||||
actorName: getDisplayName(ctx.user),
|
||||
})
|
||||
} catch (error) {
|
||||
return rethrowPositionConfigError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function removePerformanceSurveyAdminPositionGroup(
|
||||
session: Session | null,
|
||||
groupId: string,
|
||||
body: any,
|
||||
) {
|
||||
const ctx = await getPerformanceSurveyContext(session)
|
||||
ensureSystemAdministrator(ctx)
|
||||
try {
|
||||
return await removePerformanceSurveyPositionGroup({
|
||||
groupId,
|
||||
mode: ['auto', 'archive', 'delete'].includes(body?.mode) ? body.mode : 'auto',
|
||||
reason: clean(body?.reason) || '',
|
||||
actorUserId: ctx.user.id,
|
||||
actorName: getDisplayName(ctx.user),
|
||||
})
|
||||
} catch (error) {
|
||||
return rethrowPositionConfigError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function previewPerformanceSurveyAdminPositionGroup(
|
||||
session: Session | null,
|
||||
groupId: string,
|
||||
params: URLSearchParams,
|
||||
) {
|
||||
const ctx = await getPerformanceSurveyContext(session)
|
||||
ensureSystemAdministrator(ctx)
|
||||
try {
|
||||
return await previewPerformanceSurveyPositionPlanImpact({
|
||||
groupId,
|
||||
effectiveMonth: clean(params.get('month')) || '',
|
||||
})
|
||||
} catch (error) {
|
||||
return rethrowPositionConfigError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function removePerformanceSurveyAdminTemplate(
|
||||
session: Session | null,
|
||||
templateId: string,
|
||||
body: any,
|
||||
) {
|
||||
const ctx = await getPerformanceSurveyContext(session)
|
||||
ensureSystemAdministrator(ctx)
|
||||
try {
|
||||
return await removePerformanceSurveyTemplate({
|
||||
templateId,
|
||||
mode: ['auto', 'archive', 'delete'].includes(body?.mode) ? body.mode : 'auto',
|
||||
reason: clean(body?.reason) || '',
|
||||
actorUserId: ctx.user.id,
|
||||
actorName: getDisplayName(ctx.user),
|
||||
})
|
||||
} catch (error) {
|
||||
return rethrowPositionConfigError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPerformanceSurveyAdminPositionAssignments(
|
||||
session: Session | null,
|
||||
params: URLSearchParams,
|
||||
) {
|
||||
const ctx = await getPerformanceSurveyContext(session)
|
||||
ensureSystemAdministrator(ctx)
|
||||
try {
|
||||
return await listPerformanceSurveyManualPositionAssignments(params)
|
||||
} catch (error) {
|
||||
return rethrowPositionConfigError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPerformanceSurveyAdminPositionAssignments(session: Session | null, body: any) {
|
||||
const ctx = await getPerformanceSurveyContext(session)
|
||||
ensureSystemAdministrator(ctx)
|
||||
try {
|
||||
return await createPerformanceSurveyManualPositionAssignments({
|
||||
employeeIds: body?.employeeIds,
|
||||
positionGroupId: body?.positionGroupId,
|
||||
assignmentType: body?.assignmentType,
|
||||
effectiveFromMonth: body?.effectiveFromMonth,
|
||||
effectiveToMonth: body?.effectiveToMonth,
|
||||
reason: body?.reason,
|
||||
actorUserId: ctx.user.id,
|
||||
actorName: getDisplayName(ctx.user),
|
||||
})
|
||||
} catch (error) {
|
||||
return rethrowPositionConfigError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePerformanceSurveyAdminPositionAssignment(
|
||||
session: Session | null,
|
||||
assignmentId: string,
|
||||
body: any,
|
||||
) {
|
||||
const ctx = await getPerformanceSurveyContext(session)
|
||||
ensureSystemAdministrator(ctx)
|
||||
try {
|
||||
return await updatePerformanceSurveyManualPositionAssignment({
|
||||
assignmentId,
|
||||
positionGroupId: body?.positionGroupId,
|
||||
assignmentType: body?.assignmentType,
|
||||
effectiveFromMonth: body?.effectiveFromMonth,
|
||||
effectiveToMonth: body?.effectiveToMonth,
|
||||
reason: body?.reason,
|
||||
actorUserId: ctx.user.id,
|
||||
actorName: getDisplayName(ctx.user),
|
||||
})
|
||||
} catch (error) {
|
||||
return rethrowPositionConfigError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function archivePerformanceSurveyAdminPositionAssignment(
|
||||
session: Session | null,
|
||||
assignmentId: string,
|
||||
body: any,
|
||||
) {
|
||||
const ctx = await getPerformanceSurveyContext(session)
|
||||
ensureSystemAdministrator(ctx)
|
||||
try {
|
||||
return await archivePerformanceSurveyManualPositionAssignment({
|
||||
assignmentId,
|
||||
reason: clean(body?.reason) || '',
|
||||
actorUserId: ctx.user.id,
|
||||
actorName: getDisplayName(ctx.user),
|
||||
})
|
||||
} catch (error) {
|
||||
return rethrowPositionConfigError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPerformanceSurveyAdminConfigAuditLogs(
|
||||
session: Session | null,
|
||||
params: URLSearchParams,
|
||||
) {
|
||||
const ctx = await getPerformanceSurveyContext(session)
|
||||
ensureSystemAdministrator(ctx)
|
||||
return listPerformanceSurveyConfigAuditLogs(params)
|
||||
}
|
||||
|
||||
export async function searchPerformanceSurveyEmployees(session: Session | null, params: URLSearchParams) {
|
||||
const ctx = await getPerformanceSurveyContext(session)
|
||||
const search = clean(params.get('search')) || ''
|
||||
@ -1226,9 +1428,12 @@ export async function searchPerformanceSurveyEmployees(session: Session | null,
|
||||
}
|
||||
|
||||
if (positionGroupCode) {
|
||||
const groups = await listPerformanceSurveyPositionGroups(assessmentMonth)
|
||||
const membershipByEmployeeId = await getPerformanceSurveyPositionMembershipsForEmployees(
|
||||
employees,
|
||||
assessmentMonth,
|
||||
)
|
||||
employees = employees.filter((employee) => (
|
||||
resolvePerformanceSurveyPositionMembership(employee, groups).group?.code === positionGroupCode
|
||||
membershipByEmployeeId.get(employee.id)?.group?.code === positionGroupCode
|
||||
)).slice(0, limit)
|
||||
}
|
||||
|
||||
@ -2007,8 +2212,8 @@ export async function getPerformanceSurveyDashboard(session: Session | null, par
|
||||
const monthValue = clean(params?.get('month')) || currentMonth.value
|
||||
const positionGroups = await listPerformanceSurveyPositionGroups(monthValue)
|
||||
const personalMembership = ctx.employee
|
||||
? resolvePerformanceSurveyPositionMembership(ctx.employee, positionGroups)
|
||||
: { status: 'unmatched' as const, group: null, matches: [] }
|
||||
? await getPerformanceSurveyPositionGroupForEmployee(ctx.employee, monthValue)
|
||||
: { status: 'unmatched' as const, group: null, matches: [], source: 'automatic' as const, manualAssignmentId: null }
|
||||
const requestedGroupCode = clean(params?.get('positionGroupCode'))
|
||||
let selectedGroup = dashboardView === 'personal'
|
||||
? personalMembership.group
|
||||
|
||||
@ -0,0 +1,139 @@
|
||||
ALTER TABLE "performance_survey_position_plans"
|
||||
ADD COLUMN IF NOT EXISTS "group_config" JSONB;
|
||||
|
||||
ALTER TABLE "performance_survey_position_groups"
|
||||
ADD COLUMN IF NOT EXISTS "is_system" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS "archived_at" TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS "archived_by_user_id" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "archived_by_name" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "archive_reason" VARCHAR(500);
|
||||
|
||||
UPDATE "performance_survey_position_groups"
|
||||
SET "is_system" = TRUE
|
||||
WHERE "code" IN (
|
||||
'ai_planning_creative',
|
||||
'ai_planning_general',
|
||||
'ai_technical_designer',
|
||||
'brand_designer',
|
||||
'director',
|
||||
'camera',
|
||||
'editing',
|
||||
'retouching'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "performance_survey_position_group_archived_sort_idx"
|
||||
ON "performance_survey_position_groups" ("archived_at", "sort_order");
|
||||
|
||||
ALTER TABLE "performance_survey_templates"
|
||||
ADD COLUMN IF NOT EXISTS "archived_at" TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS "archived_by_user_id" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "archived_by_name" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "archive_reason" VARCHAR(500);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "performance_survey_templates_archived_at_idx"
|
||||
ON "performance_survey_templates" ("archived_at");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "performance_survey_employee_position_assignments" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"employee_profile_id" TEXT NOT NULL,
|
||||
"assignment_type" TEXT NOT NULL,
|
||||
"position_group_id" TEXT,
|
||||
"start_month" TEXT NOT NULL,
|
||||
"end_month" TEXT,
|
||||
"reason" VARCHAR(500) NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'active',
|
||||
"created_by_user_id" TEXT NOT NULL,
|
||||
"created_by_name" TEXT NOT NULL,
|
||||
"archived_at" TIMESTAMP(3),
|
||||
"archived_by_user_id" TEXT,
|
||||
"archived_by_name" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "performance_survey_manual_assignment_employee_fkey"
|
||||
FOREIGN KEY ("employee_profile_id") REFERENCES "performance_survey_employee_profiles"("id")
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "performance_survey_manual_assignment_group_fkey"
|
||||
FOREIGN KEY ("position_group_id") REFERENCES "performance_survey_position_groups"("id")
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "performance_survey_manual_assignment_type_check"
|
||||
CHECK (
|
||||
("assignment_type" = 'position' AND "position_group_id" IS NOT NULL)
|
||||
OR ("assignment_type" = 'excluded' AND "position_group_id" IS NULL)
|
||||
),
|
||||
CONSTRAINT "performance_survey_manual_assignment_month_range_check"
|
||||
CHECK ("end_month" IS NULL OR "end_month" >= "start_month"),
|
||||
CONSTRAINT "performance_survey_manual_assignment_start_month_check"
|
||||
CHECK ("start_month" ~ '^[0-9]{4}-(0[1-9]|1[0-2])$'),
|
||||
CONSTRAINT "performance_survey_manual_assignment_end_month_check"
|
||||
CHECK ("end_month" IS NULL OR "end_month" ~ '^[0-9]{4}-(0[1-9]|1[0-2])$')
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "performance_survey_manual_assignment_employee_range_idx"
|
||||
ON "performance_survey_employee_position_assignments"
|
||||
("employee_profile_id", "status", "start_month", "end_month");
|
||||
CREATE INDEX IF NOT EXISTS "performance_survey_manual_assignment_group_idx"
|
||||
ON "performance_survey_employee_position_assignments" ("position_group_id");
|
||||
CREATE INDEX IF NOT EXISTS "performance_survey_manual_assignment_status_range_idx"
|
||||
ON "performance_survey_employee_position_assignments" ("status", "start_month", "end_month");
|
||||
|
||||
CREATE OR REPLACE FUNCTION enforce_performance_survey_manual_assignment_no_overlap()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF NEW."status" <> 'active' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended(NEW."employee_profile_id", 0));
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM "performance_survey_employee_position_assignments" existing
|
||||
WHERE existing."employee_profile_id" = NEW."employee_profile_id"
|
||||
AND existing."status" = 'active'
|
||||
AND existing."id" <> NEW."id"
|
||||
AND existing."start_month" <= COALESCE(NEW."end_month", '9999-12')
|
||||
AND COALESCE(existing."end_month", '9999-12') >= NEW."start_month"
|
||||
) THEN
|
||||
RAISE EXCEPTION 'manual position assignment month range overlaps an existing assignment';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS performance_survey_manual_assignment_no_overlap
|
||||
ON "performance_survey_employee_position_assignments";
|
||||
CREATE TRIGGER performance_survey_manual_assignment_no_overlap
|
||||
BEFORE INSERT OR UPDATE ON "performance_survey_employee_position_assignments"
|
||||
FOR EACH ROW EXECUTE FUNCTION enforce_performance_survey_manual_assignment_no_overlap();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "performance_survey_config_audit_logs" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"action" TEXT NOT NULL,
|
||||
"target_type" TEXT NOT NULL,
|
||||
"target_id" TEXT,
|
||||
"actor_user_id" TEXT NOT NULL,
|
||||
"actor_name" TEXT NOT NULL,
|
||||
"reason" VARCHAR(500),
|
||||
"before_snapshot" JSONB,
|
||||
"after_snapshot" JSONB,
|
||||
"metadata" JSONB,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "performance_survey_config_audit_target_idx"
|
||||
ON "performance_survey_config_audit_logs" ("target_type", "target_id", "created_at");
|
||||
CREATE INDEX IF NOT EXISTS "performance_survey_config_audit_actor_idx"
|
||||
ON "performance_survey_config_audit_logs" ("actor_user_id", "created_at");
|
||||
CREATE INDEX IF NOT EXISTS "performance_survey_config_audit_created_idx"
|
||||
ON "performance_survey_config_audit_logs" ("created_at");
|
||||
|
||||
CREATE OR REPLACE FUNCTION prevent_performance_survey_config_audit_mutation()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'performance survey config audit logs are immutable';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS performance_survey_config_audit_immutable
|
||||
ON "performance_survey_config_audit_logs";
|
||||
CREATE TRIGGER performance_survey_config_audit_immutable
|
||||
BEFORE UPDATE OR DELETE ON "performance_survey_config_audit_logs"
|
||||
FOR EACH ROW EXECUTE FUNCTION prevent_performance_survey_config_audit_mutation();
|
||||
@ -516,6 +516,7 @@ model PerformanceSurveyEmployeeProfile {
|
||||
submittedWorkItems PerformanceSurveyWorkItem[] @relation("PerformanceSurveyWorkItemSubmitter")
|
||||
workItemScores PerformanceSurveyWorkItemScore[] @relation("PerformanceSurveyWorkItemScoreEvaluator")
|
||||
notifications PerformanceSurveyNotification[]
|
||||
manualPositionAssignments PerformanceSurveyEmployeePositionAssignment[]
|
||||
|
||||
@@index([displayName])
|
||||
@@index([dingEmail])
|
||||
@ -554,12 +555,17 @@ model PerformanceSurveyTemplate {
|
||||
questions Json
|
||||
scoreBands Json @map("score_bands")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
archivedAt DateTime? @map("archived_at")
|
||||
archivedByUserId String? @map("archived_by_user_id")
|
||||
archivedByName String? @map("archived_by_name")
|
||||
archiveReason String? @map("archive_reason") @db.VarChar(500)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
versions PerformanceSurveyTemplateVersion[]
|
||||
|
||||
@@index([surveyType])
|
||||
@@index([isActive])
|
||||
@@index([archivedAt])
|
||||
@@map("performance_survey_templates")
|
||||
}
|
||||
|
||||
@ -590,13 +596,20 @@ model PerformanceSurveyPositionGroup {
|
||||
name String
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
isSystem Boolean @default(false) @map("is_system")
|
||||
archivedAt DateTime? @map("archived_at")
|
||||
archivedByUserId String? @map("archived_by_user_id")
|
||||
archivedByName String? @map("archived_by_name")
|
||||
archiveReason String? @map("archive_reason") @db.VarChar(500)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
rules PerformanceSurveyPositionRule[]
|
||||
plans PerformanceSurveyPositionPlan[]
|
||||
surveys PerformanceSurvey[]
|
||||
manualAssignments PerformanceSurveyEmployeePositionAssignment[]
|
||||
|
||||
@@index([isActive, sortOrder], map: "performance_survey_position_group_active_sort_idx")
|
||||
@@index([archivedAt, sortOrder], map: "performance_survey_position_group_archived_sort_idx")
|
||||
@@map("performance_survey_position_groups")
|
||||
}
|
||||
|
||||
@ -623,6 +636,7 @@ model PerformanceSurveyPositionPlan {
|
||||
status String @default("draft")
|
||||
effectiveMonth String? @map("effective_month")
|
||||
matchRules Json @default("[]") @map("match_rules")
|
||||
groupConfig Json? @map("group_config")
|
||||
publishedAt DateTime? @map("published_at")
|
||||
createdByUserId String? @map("created_by_user_id")
|
||||
createdByName String? @map("created_by_name")
|
||||
@ -636,6 +650,30 @@ model PerformanceSurveyPositionPlan {
|
||||
@@map("performance_survey_position_plans")
|
||||
}
|
||||
|
||||
model PerformanceSurveyEmployeePositionAssignment {
|
||||
id String @id @default(uuid())
|
||||
employeeProfileId String @map("employee_profile_id")
|
||||
assignmentType String @map("assignment_type")
|
||||
positionGroupId String? @map("position_group_id")
|
||||
startMonth String @map("start_month")
|
||||
endMonth String? @map("end_month")
|
||||
reason String @db.VarChar(500)
|
||||
status String @default("active")
|
||||
createdByUserId String @map("created_by_user_id")
|
||||
createdByName String @map("created_by_name")
|
||||
archivedAt DateTime? @map("archived_at")
|
||||
archivedByUserId String? @map("archived_by_user_id")
|
||||
archivedByName String? @map("archived_by_name")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
employeeProfile PerformanceSurveyEmployeeProfile @relation(fields: [employeeProfileId], references: [id], onDelete: Restrict)
|
||||
positionGroup PerformanceSurveyPositionGroup? @relation(fields: [positionGroupId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([employeeProfileId, status, startMonth, endMonth], map: "performance_survey_manual_assignment_employee_range_idx")
|
||||
@@index([positionGroupId], map: "performance_survey_manual_assignment_group_idx")
|
||||
@@index([status, startMonth, endMonth], map: "performance_survey_manual_assignment_status_range_idx")
|
||||
@@map("performance_survey_employee_position_assignments")
|
||||
}
|
||||
|
||||
model PerformanceSurveyPositionPlanItem {
|
||||
id String @id @default(uuid())
|
||||
positionPlanId String @map("position_plan_id")
|
||||
@ -894,6 +932,25 @@ model PerformanceSurveyNotification {
|
||||
@@map("performance_survey_notifications")
|
||||
}
|
||||
|
||||
model PerformanceSurveyConfigAuditLog {
|
||||
id String @id @default(uuid())
|
||||
action String
|
||||
targetType String @map("target_type")
|
||||
targetId String? @map("target_id")
|
||||
actorUserId String @map("actor_user_id")
|
||||
actorName String @map("actor_name")
|
||||
reason String? @db.VarChar(500)
|
||||
beforeSnapshot Json? @map("before_snapshot")
|
||||
afterSnapshot Json? @map("after_snapshot")
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([targetType, targetId, createdAt], map: "performance_survey_config_audit_target_idx")
|
||||
@@index([actorUserId, createdAt], map: "performance_survey_config_audit_actor_idx")
|
||||
@@index([createdAt], map: "performance_survey_config_audit_created_idx")
|
||||
@@map("performance_survey_config_audit_logs")
|
||||
}
|
||||
|
||||
model VideoTranscodeProgress {
|
||||
id String @id @default(uuid())
|
||||
materialId String @map("material_id")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user