265 lines
11 KiB
TypeScript
265 lines
11 KiB
TypeScript
'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
|
||
|