flnxi-Wu/app/admin/_components/users/DepartmentHierarchy.tsx

265 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { memo, useMemo } from 'react'
import type { DepartmentNode, DepartmentUserGrouping, User } from '../types'
import { cardStyles, cn } from '@/lib/ui-styles'
interface DepartmentHierarchyProps {
departmentTree: DepartmentNode[]
departmentUsersMap: Map<string, DepartmentUserGrouping>
departmentMaterialCounts: Map<string, number>
collapsedDepartments: Set<string>
onToggleDepartmentCollapse: (name: string) => void
renderUserRow: (user: User, isManager?: boolean) => JSX.Element
totalUsers: number
}
function DepartmentHierarchyComponent({
departmentTree,
departmentUsersMap,
departmentMaterialCounts,
collapsedDepartments,
onToggleDepartmentCollapse,
renderUserRow,
totalUsers,
}: DepartmentHierarchyProps) {
const sections = useMemo(() => {
const visitedDepartments = new Set<string>()
const renderDepartmentUsers = (entry?: DepartmentUserGrouping) => {
const managerCount = entry?.managers.length ?? 0
const regularCount = entry?.regulars.length ?? 0
const total = managerCount + regularCount
if (!total) {
return (
<div
className={cn(
cardStyles.flat,
'px-4 py-2 border-dashed border-2 text-xs text-slate-500 dark:text-slate-400',
)}
>
暂无成员
</div>
)
}
return (
<div className="space-y-3">
{managerCount > 0 && (
<div className="space-y-1">
<div className="text-xs font-semibold text-slate-600 dark:text-slate-300">管理员</div>
<div className="space-y-1">{entry!.managers.map((user) => renderUserRow(user, true))}</div>
</div>
)}
{regularCount > 0 && (
<div className="space-y-1">
<div className="text-xs font-semibold text-slate-500 dark:text-slate-400">成员</div>
<div className="space-y-1">{entry!.regulars.map((user) => renderUserRow(user))}</div>
</div>
)}
</div>
)
}
const renderDepartmentBranch = (node: DepartmentNode, depth = 0): JSX.Element | null => {
visitedDepartments.add(node.fullName)
const entry = departmentUsersMap.get(node.fullName)
const memberCount = (entry?.managers.length ?? 0) + (entry?.regulars.length ?? 0)
const materialCount = departmentMaterialCounts.get(node.fullName) || 0
const managerNames = (entry?.managers || []).map((manager) => manager.username).filter(Boolean)
const children = node.children
.map((child) => renderDepartmentBranch(child, depth + 1))
.filter(Boolean) as JSX.Element[]
const isCollapsed = collapsedDepartments.has(node.fullName)
const canToggle = memberCount > 0 || children.length > 0
const depthLevel = Math.min(depth, 2)
const depthContainerClasses =
depthLevel === 0
? 'bg-slate-50 dark:bg-slate-900/50 border-slate-400 dark:border-slate-600'
: depthLevel === 1
? 'bg-white dark:bg-slate-900/30 border-slate-300 dark:border-slate-700'
: 'bg-white dark:bg-slate-900/20 border-slate-200 dark:border-slate-800'
return (
<div key={node.fullName} className="space-y-2">
<div
className={cn(
'group flex items-center justify-between px-4 py-2.5 border-l-[3px] rounded-lg transition-colors duration-200 hover:bg-slate-100 dark:hover:bg-slate-800',
depthContainerClasses,
)}
onClick={() => {
if (!canToggle) return
onToggleDepartmentCollapse(node.fullName)
}}
style={{ marginLeft: depth * 12 }}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
{canToggle ? (
<button
type="button"
className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors duration-200"
title={isCollapsed ? '展开' : '折叠'}
onClick={(e) => {
e.stopPropagation()
onToggleDepartmentCollapse(node.fullName)
}}
>
<svg
className={cn(
'w-4 h-4 transform transition-transform duration-200 ease-out text-slate-600 dark:text-slate-300',
!isCollapsed && 'rotate-90',
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
) : (
<span className="flex-shrink-0 w-6 h-6" />
)}
<div className="flex flex-col min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-slate-900 dark:text-white">{node.fullName}</span>
{!!managerNames.length && (
<div className="flex items-center gap-1.5 flex-wrap text-xs text-slate-500 dark:text-slate-400">
<span>管理员:</span>
{managerNames.map((name) => (
<span
key={name}
className="px-2 py-0.5 border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 text-xs font-semibold rounded-md"
>
{name}
</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-3 mt-0.5 text-xs text-slate-500 dark:text-slate-400">
<span>成员:{memberCount}</span>
<span>素材:{materialCount}</span>
</div>
</div>
</div>
</div>
{!isCollapsed && (
<div className="ml-6 space-y-2 animate-in fade-in slide-in-from-top-2 duration-300">
{renderDepartmentUsers(entry)}
{children}
</div>
)}
</div>
)
}
const sections: JSX.Element[] = []
const rootCollapsed = collapsedDepartments.has('ROOT')
const departmentBranches = departmentTree
.map((node) => {
visitedDepartments.add(node.fullName)
return renderDepartmentBranch(node)
})
.filter(Boolean) as JSX.Element[]
sections.push(
<div key="user-tree-root" className="space-y-2">
<div className="group flex items-center justify-between px-4 py-2.5 bg-slate-50 dark:bg-slate-900/50 border-l-4 border-slate-500 dark:border-slate-500 rounded-lg transition-colors duration-200 hover:bg-slate-100 dark:hover:bg-slate-800">
<div className="flex items-center gap-3">
<button
type="button"
className="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-lg hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors duration-200"
title={rootCollapsed ? '展开' : '折叠'}
onClick={() => onToggleDepartmentCollapse('ROOT')}
>
<svg
className={cn(
'w-5 h-5 transform transition-transform duration-200 text-slate-600 dark:text-slate-300',
!rootCollapsed && 'rotate-90',
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
<div className="flex flex-col">
<span className="text-base font-semibold text-slate-900 dark:text-white">主公司</span>
<span className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">一级分支:{departmentTree.length}</span>
</div>
</div>
<span className="text-xs text-slate-500 dark:text-slate-400">成员总数:{totalUsers}</span>
</div>
{!rootCollapsed && (
<div className="mt-2 space-y-2 ml-6 animate-in fade-in slide-in-from-top-2 duration-300">
{departmentBranches.length ? (
departmentBranches
) : (
<div className="text-xs text-slate-500 dark:text-slate-400 px-4 py-2">暂无分支,请先在架构管理中创建分支</div>
)}
</div>
)}
</div>,
)
const orphanNames = Array.from(departmentUsersMap.keys()).filter(
(name) => name !== '未分配部门' && !visitedDepartments.has(name),
)
orphanNames.forEach((name) => {
const entry = departmentUsersMap.get(name)
const total = (entry?.managers.length ?? 0) + (entry?.regulars.length ?? 0)
if (!total) return
sections.push(
<div key={`orphan-${name}`} className="space-y-2">
<div className="px-4 py-2 bg-amber-50 dark:bg-amber-900/30 border-l-4 border-amber-400 dark:border-amber-500 rounded-lg">
<div className="text-sm font-semibold text-amber-800 dark:text-amber-200">{name}(未在架构中)</div>
</div>
<div className="ml-6">{renderDepartmentUsers(entry)}</div>
</div>,
)
})
const unassignedEntry = departmentUsersMap.get('未分配部门')
const unassignedCount = (unassignedEntry?.managers.length ?? 0) + (unassignedEntry?.regulars.length ?? 0)
if (unassignedCount > 0) {
sections.push(
<div key="unassigned" className="space-y-2">
<div className="px-4 py-2 bg-slate-100 dark:bg-slate-800 border-l-4 border-slate-400 dark:border-slate-600 rounded-lg">
<div className="text-sm font-semibold text-slate-800 dark:text-slate-100">未分配部门</div>
</div>
<div className="ml-6">{renderDepartmentUsers(unassignedEntry)}</div>
</div>,
)
}
if (!sections.length) {
sections.push(
<div key="empty-users" className="text-center py-12 text-slate-500 dark:text-slate-400">
暂无用户
</div>,
)
}
return sections
}, [
collapsedDepartments,
departmentMaterialCounts,
departmentTree,
departmentUsersMap,
onToggleDepartmentCollapse,
renderUserRow,
totalUsers,
])
return <div className="space-y-2">{sections}</div>
}
export const DepartmentHierarchy = memo(DepartmentHierarchyComponent)
export default DepartmentHierarchy