fix: 展示岗位人员并修复公共看板权限

This commit is contained in:
江林铖 2026-07-31 09:40:55 +08:00
parent 5590c5a454
commit 19174d5050
6 changed files with 171 additions and 35 deletions

View File

@ -37,6 +37,17 @@ const config = {
isActive: true,
status: 'ready',
memberCount: 6,
members: [{
id: 'employee-ruolan',
displayName: '若兰',
departmentName: '品牌创意B部',
positionTitle: '营销策划主管',
}, {
id: 'employee-muyi',
displayName: '木一',
departmentName: '品牌创意A部',
positionTitle: '品牌策划',
}],
rules: [{
id: 'rule-1',
departmentPathPrefix: '集团/品牌中心',
@ -59,6 +70,7 @@ const config = {
isActive: true,
status: 'pending',
memberCount: 2,
members: [],
rules: [],
effectivePlan: null,
draftPlan: null,
@ -397,6 +409,25 @@ describe('PerformanceSurveyAdminConfig', () => {
})))
})
test('岗位人数可展开查看具体人员并可收起', async () => {
const request = createRequest()
render(<PerformanceSurveyAdminConfig mode="positions" request={request} />)
const expandButton = (await screen.findAllByRole('button', { name: '展开人员名单' }))[0]
expect(expandButton).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('若兰')).not.toBeInTheDocument()
fireEvent.click(expandButton)
expect(expandButton).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByRole('list', { name: '品牌设计师人员名单' })).toBeInTheDocument()
expect(screen.getByText('若兰')).toBeInTheDocument()
expect(screen.getByText('品牌创意B部 · 营销策划主管')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '收起人员名单' }))
expect(screen.queryByText('若兰')).not.toBeInTheDocument()
})
test('人员批量分配提交最终 REST 请求体', async () => {
const request = createRequest()
render(<PerformanceSurveyAdminConfig mode="people" request={request} />)

View File

@ -96,6 +96,13 @@ type PositionGroupRecord = {
isActive: boolean
status: 'ready' | 'pending' | 'disabled'
memberCount: number
members?: Array<{
id: string
displayName: string
departmentName?: string | null
departmentPath?: string | null
positionTitle?: string | null
}>
rules: Array<{
id: string
departmentPathPrefix: string
@ -837,6 +844,7 @@ function PositionConfigWorkspace({
const [editing, setEditing] = useState(Boolean(selected?.draftPlan) || !selected?.effectivePlan)
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
const [expandedMemberGroupIds, setExpandedMemberGroupIds] = useState<Set<string>>(() => new Set())
const editorRef = useRef<HTMLDivElement>(null)
useEffect(() => {
@ -850,6 +858,14 @@ function PositionConfigWorkspace({
}, [onDirtyChange, selected])
const markDirty = () => onDirtyChange(true)
const toggleMemberList = (groupId: string) => {
setExpandedMemberGroupIds((current) => {
const next = new Set(current)
if (next.has(groupId)) next.delete(groupId)
else next.add(groupId)
return next
})
}
const availableTemplateVersions = useMemo(() => data.templates.flatMap((template) => {
const availability = getTemplateAvailability(template, month)
@ -1092,20 +1108,66 @@ function PositionConfigWorkspace({
</div>
{data.groups.map((group) => {
const groupState = getPositionGroupState(group)
const memberListId = `position-group-members-${group.id}`
const groupLabelId = `position-group-label-${group.id}`
const isMemberListExpanded = expandedMemberGroupIds.has(group.id)
return (
<button
key={group.id}
type="button"
onClick={() => void selectGroup(group.id)}
aria-label={`${group.name},${group.memberCount} 人,${groupState.label}`}
className={`grid w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b border-[#eeeeee] px-4 py-3 text-left ${selected.id === group.id ? 'bg-[#f1f4ff]' : 'hover:bg-[#fafafa]'}`}
>
<span className="min-w-0">
<span className="block truncate text-[13px] font-bold text-[#1a1c1c]">{group.name}</span>
<span className="mt-0.5 block text-[11px] text-[#5f5e5e]">{group.memberCount} 人</span>
</span>
<StatusDot label={groupState.label} tone={groupState.tone} />
</button>
<div key={group.id} className="border-b border-[#eeeeee]">
<div className={`grid grid-cols-[minmax(0,1fr)_auto] items-stretch ${selected.id === group.id ? 'bg-[#f1f4ff]' : 'hover:bg-[#fafafa]'}`}>
<button
type="button"
onClick={() => void selectGroup(group.id)}
aria-label={`${group.name},${group.memberCount} 人,${groupState.label}`}
aria-pressed={selected.id === group.id}
className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 px-4 py-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[#0050cb]"
>
<span className="min-w-0">
<span id={groupLabelId} className="block truncate text-[13px] font-bold text-[#1a1c1c]">{group.name}</span>
<span className="mt-0.5 block text-[11px] text-[#5f5e5e]">{group.memberCount} 人</span>
</span>
<StatusDot label={groupState.label} tone={groupState.tone} />
</button>
<div className="flex items-center pr-2">
<button
type="button"
onClick={() => toggleMemberList(group.id)}
aria-label={`${isMemberListExpanded ? '收起' : '展开'}人员名单`}
aria-describedby={groupLabelId}
aria-expanded={isMemberListExpanded}
aria-controls={memberListId}
className="grid min-h-11 min-w-11 place-items-center rounded text-[#5f5e5e] outline-none hover:bg-black/5 hover:text-[#1a1c1c] focus-visible:ring-2 focus-visible:ring-[#0050cb]"
>
<svg
aria-hidden="true"
viewBox="0 0 20 20"
fill="none"
className={`h-4 w-4 transition-transform duration-150 ${isMemberListExpanded ? 'rotate-180' : ''}`}
>
<path d="m6 8 4 4 4-4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
</div>
{isMemberListExpanded && (
<div id={memberListId} className="border-t border-[#eeeeee] bg-[#fafafa] px-4 py-2.5">
{(group.members?.length || 0) === 0 ? (
<p className="text-[11px] text-[#777575]">当前未配置人员</p>
) : (
<ul className="max-h-64 divide-y divide-[#e9e9e9] overflow-y-auto" aria-label={`${group.name}人员名单`}>
{group.members?.map((member) => {
const memberMeta = [member.departmentName, member.positionTitle].filter(Boolean).join(' · ')
return (
<li key={member.id} className="py-2">
<span className="block text-[12px] font-semibold text-[#1a1c1c]">{member.displayName}</span>
{memberMeta && <span className="mt-0.5 block truncate text-[10px] text-[#777575]">{memberMeta}</span>}
</li>
)
})}
</ul>
)}
</div>
)}
</div>
)
})}
</aside>

View File

@ -299,6 +299,54 @@ describe('performance survey position admin', () => {
expect(prisma.performanceSurveyEmployeeProfile.findMany).not.toHaveBeenCalled()
})
test('岗位管理配置返回每个岗位的具体人员名单', async () => {
;(prisma.performanceSurveyPositionGroup.findMany as jest.Mock).mockResolvedValue([{
id: 'group-creative',
code: 'ai_planning_creative',
name: 'AI策划-创意部',
sortOrder: 10,
isActive: true,
isSystem: true,
archivedAt: null,
archiveReason: null,
rules: [],
plans: [],
_count: { surveys: 1, manualAssignments: 1 },
}])
;(prisma.performanceSurveyTemplate.findMany as jest.Mock).mockResolvedValue([])
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([{
id: 'employee-ruolan',
displayName: '若兰',
departmentName: '品牌创意B部',
departmentPath: '集团/品牌中心/品牌创意B部',
positionTitle: '营销策划主管',
departmentMemberships: [],
}])
;(prisma.performanceSurveyEmployeePositionAssignment.findMany as jest.Mock).mockResolvedValue([{
id: 'assignment-ruolan',
employeeProfileId: 'employee-ruolan',
assignmentType: 'position',
positionGroupId: 'group-creative',
status: 'active',
startMonth: '2026-07',
endMonth: null,
createdAt: new Date('2026-07-01T00:00:00.000Z'),
}])
await expect(getPerformanceSurveyPositionAdminConfig('2026-07', 'positions')).resolves.toMatchObject({
groups: [{
id: 'group-creative',
memberCount: 1,
members: [{
id: 'employee-ruolan',
displayName: '若兰',
departmentName: '品牌创意B部',
positionTitle: '营销策划主管',
}],
}],
})
})
test('复制模板会创建独立模板和完整草稿版本', async () => {
const definition = structuredClone(DEFAULT_PERFORMANCE_SURVEY_TEMPLATES[0])
;(prisma.performanceSurveyTemplateVersion.findUnique as jest.Mock).mockResolvedValue({

View File

@ -623,7 +623,7 @@ describe('performance survey service policies', () => {
expect(cell.rawMaxTotal).toBe(20)
})
test('公共看板关系查询继续应用领导组织范围及部门和人员筛选', async () => {
test('M级系统管理员查看公共看板时不再叠加领导组织范围', async () => {
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue({
...baseEmployee,
levelType: 'M',
@ -640,26 +640,14 @@ describe('performance survey service policies', () => {
const employeeWhere = (prisma.performanceSurveyTarget.findMany as jest.Mock).mock.calls[0][0]
.where.employeeProfile.is
expect(employeeWhere.AND).toEqual(expect.arrayContaining([
{
OR: [
{ departmentPath: '集团A/品牌中心/策划部' },
{ departmentPath: { startsWith: '集团A/品牌中心/策划部/' } },
{
departmentMemberships: {
some: {
OR: [
{ departmentPath: '集团A/品牌中心/策划部' },
{ departmentPath: { startsWith: '集团A/品牌中心/策划部/' } },
],
},
},
},
],
},
expect(employeeWhere).toEqual({
AND: [
expect.objectContaining({ OR: expect.any(Array) }),
{ id: 'employee-public' },
]))
],
})
expect(JSON.stringify(employeeWhere)).toContain('集团A/品牌中心')
expect(JSON.stringify(employeeWhere)).not.toContain('集团A/品牌中心/策划部')
expect(getPerformanceSurveyAccountProfileWhere).not.toHaveBeenCalled()
})

View File

@ -1043,9 +1043,15 @@ export async function getPerformanceSurveyPositionAdminConfig(
),
}))
const memberCountByGroup = new Map<string, number>()
const membersByGroup = new Map<string, ReturnType<typeof serializePositionEmployee>[]>()
for (const item of memberships) {
if (item.membership.group) {
memberCountByGroup.set(item.membership.group.id, (memberCountByGroup.get(item.membership.group.id) || 0) + 1)
const groupId = item.membership.group.id
memberCountByGroup.set(groupId, (memberCountByGroup.get(groupId) || 0) + 1)
membersByGroup.set(groupId, [
...(membersByGroup.get(groupId) || []),
serializePositionEmployee(item.employee),
])
}
}
const unmatched = memberships
@ -1085,6 +1091,7 @@ export async function getPerformanceSurveyPositionAdminConfig(
archiveReason: group.archiveReason,
status: runtime.status,
memberCount: memberCountByGroup.get(group.id) || 0,
members: membersByGroup.get(group.id) || [],
canHardDelete: !group.isSystem
&& group._count.surveys === 0
&& group._count.manualAssignments === 0

View File

@ -1632,7 +1632,7 @@ export async function listPerformanceSurveyDepartments(session: Session | null,
getSurveyMonthWhere(getMonthRange(dashboardMonth)!),
],
},
...(ctx.isLeadership
...(!ctx.isSystemAdmin && ctx.isLeadership
? {
employeeProfile: {
is: ctx.scopeDepartmentPath
@ -2146,7 +2146,7 @@ function getPublicDashboardEmployeeWhere(ctx: UserContext, params?: URLSearchPar
const employeeId = clean(params?.get('employeeId'))
const and: any[] = []
if (ctx.isLeadership) {
if (!ctx.isSystemAdmin && ctx.isLeadership) {
and.push(
ctx.scopeDepartmentPath
? getScopeDepartmentPathWhere(ctx.scopeDepartmentPath)