feat: 完善考核调研管理员维护后台

This commit is contained in:
chenpei 2026-07-23 17:09:45 +08:00
parent 9d812f8c84
commit 770a39b127
11 changed files with 3767 additions and 296 deletions

View File

@ -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)

View File

@ -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

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

View File

@ -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>

View 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('岗位方案草稿已发生变化,请重新执行发布前检查')
})
})

View File

@ -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

View File

@ -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

View File

@ -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();

View File

@ -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")