662 lines
29 KiB
TypeScript
662 lines
29 KiB
TypeScript
/*
|
||
* 方向契约(云采石场 · seed 32cb33b5)
|
||
* THESIS: 组织架构页让"人"归位到真实组织树——部门即层叠的石料块,人员是块上的铭牌;
|
||
* 拒绝旧后台"平铺列表+堆卡片"的默认布局,用左右结构(左=组织采石剖面,右=人员明细)直接呈现层级与归属。
|
||
* OWN-WORLD: 浅色工业风——石材白/灰蓝地面、切割蓝(#5a66b8)唯一强调色、块状层叠卡片(细边框+轻投影)、
|
||
* 工程数据用等宽数字、状态用绿/琥珀信号色。
|
||
* STORY: 管理员打开页面即看到"公司这座山被切成一层层部门":点哪一层,右侧露出该层成员名单,
|
||
* 等级(M/P)、直属主管、在职状态像工程铭牌一样一目了然;未绑定/未确定人员单独成垛,不混入。
|
||
* FIRST VIEWPORT: 左 300px 组织树(层叠石料块,高亮当前层,节点带人数);右主区顶部=当前层路径+四格工程统计,
|
||
* 下方=人员明细表(花名/工号/职位/等级/主管/状态)。
|
||
* FORM: 云采石场方向(用户从概念骰子选定,替代脚本分配的深海潜水)。
|
||
* FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
|
||
*/
|
||
|
||
'use client'
|
||
|
||
import { useEffect, useMemo, useState, useCallback } from 'react'
|
||
import { useSession } from 'next-auth/react'
|
||
import { useRouter } from 'next/navigation'
|
||
import BoardShell from '@/components/board/BoardShell'
|
||
import {
|
||
lastOrganizationPathSegment,
|
||
personBelongsToOrganizationPath,
|
||
} from '@/lib/organization-material-permissions'
|
||
|
||
// ---------- 类型 ----------
|
||
interface OrgNode {
|
||
id: string
|
||
name: string
|
||
path: string
|
||
level: number
|
||
parentId: string | null
|
||
count: number
|
||
children: OrgNode[]
|
||
}
|
||
|
||
interface EmployeeRow {
|
||
employeeProfileId: string
|
||
userId: string | null
|
||
username: string | null
|
||
realName: string | null
|
||
displayName: string | null
|
||
jobNumber: string | null
|
||
departmentPath: string | null
|
||
departmentPaths?: string[]
|
||
departmentName: string | null
|
||
positionTitle: string | null
|
||
positionLevel: string | null
|
||
levelType: string | null
|
||
managerName: string | null
|
||
isLeadership: boolean
|
||
employmentState: string | null
|
||
accountIsActive: boolean | null
|
||
}
|
||
|
||
interface UnboundUser {
|
||
userId: string
|
||
username: string
|
||
realName: string | null
|
||
oldDepartment: string | null
|
||
}
|
||
|
||
interface OrgResponse {
|
||
tree: OrgNode[]
|
||
employees: EmployeeRow[]
|
||
unclassified: EmployeeRow[]
|
||
unbound: UnboundUser[]
|
||
summary: {
|
||
totalEmployees: number
|
||
activeEmployees: number
|
||
inactiveEmployees: number
|
||
unboundCount: number
|
||
unclassifiedCount: number
|
||
visibleCount: number
|
||
scope: string
|
||
}
|
||
}
|
||
|
||
const LEVEL_TYPE_LABEL: Record<string, string> = {
|
||
M: '管理',
|
||
P: '专业',
|
||
S: '支持',
|
||
}
|
||
|
||
function isManagementLevel(employee: EmployeeRow) {
|
||
if (employee.levelType === 'M') return true
|
||
return /^M\d+/i.test(employee.positionLevel || '')
|
||
}
|
||
|
||
const STATE_LABEL: Record<string, string> = {
|
||
ACTIVE: '在职',
|
||
RESIGNED: '离职',
|
||
UNKNOWN: '未知',
|
||
}
|
||
|
||
type OrganizationWorkspaceProps = {
|
||
embedded?: boolean
|
||
scopeRootName?: string
|
||
heading?: string
|
||
description?: string
|
||
treeAsSecondarySidebar?: boolean
|
||
}
|
||
|
||
function findOrganizationNode(nodes: OrgNode[], name: string): OrgNode | null {
|
||
for (const node of nodes) {
|
||
if (node.name === name) return node
|
||
const match = findOrganizationNode(node.children, name)
|
||
if (match) return match
|
||
}
|
||
return null
|
||
}
|
||
|
||
// ---------- 组件 ----------
|
||
export default function OrganizationWorkspace({
|
||
embedded = false,
|
||
scopeRootName,
|
||
heading = '组织架构',
|
||
description = '品牌中心在职人员 · 数据源:HR 花名册(每日 02:00 自动同步)',
|
||
treeAsSecondarySidebar = false,
|
||
}: OrganizationWorkspaceProps) {
|
||
const { data: session, status } = useSession()
|
||
const router = useRouter()
|
||
|
||
const sessionUser = session?.user as
|
||
| { role?: string; isSuperAdmin?: boolean; managedDepartment?: string | null }
|
||
| undefined
|
||
const canAccess = ['ADMIN', 'SYSTEM_ADMIN', 'DEPT_ADMIN'].includes(sessionUser?.role || '')
|
||
const isSuperAdmin = Boolean(sessionUser?.isSuperAdmin)
|
||
|
||
const [data, setData] = useState<OrgResponse | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [includeInactive, setIncludeInactive] = useState(false)
|
||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||
const [selectedPath, setSelectedPath] = useState<string | null>(null)
|
||
const [selectedGroup, setSelectedGroup] = useState<'org' | 'unclassified' | 'unbound'>('org')
|
||
const [keyword, setKeyword] = useState('')
|
||
|
||
// 权限与重定向
|
||
useEffect(() => {
|
||
if (status === 'unauthenticated') {
|
||
router.push('/login')
|
||
return
|
||
}
|
||
if (status === 'authenticated' && !canAccess) {
|
||
router.push('/')
|
||
}
|
||
}, [status, canAccess, router])
|
||
|
||
// 加载数据
|
||
const fetchData = useCallback(async () => {
|
||
setLoading(true)
|
||
setError(null)
|
||
try {
|
||
const res = await fetch(`/api/organization?includeInactive=${includeInactive}`)
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => null)
|
||
throw new Error(body?.error || '加载失败')
|
||
}
|
||
const json = (await res.json()) as OrgResponse
|
||
setData(json)
|
||
|
||
const defaultNode = scopeRootName
|
||
? findOrganizationNode(json.tree, scopeRootName)
|
||
: json.tree.find((node) => node.count > 0) ?? null
|
||
setSelectedGroup('org')
|
||
setKeyword('')
|
||
if (defaultNode) {
|
||
setExpanded(new Set([defaultNode.id]))
|
||
setSelectedPath(defaultNode.path)
|
||
} else {
|
||
setExpanded(new Set())
|
||
setSelectedPath(null)
|
||
}
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : '加载失败')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [includeInactive, scopeRootName])
|
||
|
||
useEffect(() => {
|
||
if (status === 'authenticated' && canAccess) {
|
||
void fetchData()
|
||
}
|
||
}, [status, canAccess, fetchData])
|
||
|
||
const scopeRootNode = useMemo(() => {
|
||
if (!data || !scopeRootName) return null
|
||
return findOrganizationNode(data.tree, scopeRootName)
|
||
}, [data, scopeRootName])
|
||
|
||
const scopedTree = useMemo(() => {
|
||
if (!data) return []
|
||
if (!scopeRootName) return data.tree
|
||
return scopeRootNode ? [scopeRootNode] : []
|
||
}, [data, scopeRootName, scopeRootNode])
|
||
|
||
const scopedEmployees = useMemo(() => {
|
||
if (!data) return []
|
||
if (!scopeRootName) return data.employees
|
||
if (!scopeRootNode) return []
|
||
return data.employees.filter((employee) =>
|
||
personBelongsToOrganizationPath(
|
||
employee.departmentPaths?.length ? employee.departmentPaths : [employee.departmentPath],
|
||
scopeRootNode.path,
|
||
),
|
||
)
|
||
}, [data, scopeRootName, scopeRootNode])
|
||
|
||
// 展平树为可渲染的行(带缩进深度)
|
||
const flatTree = useMemo(() => {
|
||
const rows: Array<{ node: OrgNode; depth: number }> = []
|
||
const walk = (nodes: OrgNode[], depth: number) => {
|
||
for (const node of nodes) {
|
||
rows.push({ node, depth })
|
||
if (expanded.has(node.id) && node.children.length > 0) {
|
||
walk(node.children, depth + 1)
|
||
}
|
||
}
|
||
}
|
||
walk(scopedTree, 0)
|
||
return rows
|
||
}, [scopedTree, expanded])
|
||
|
||
// 当前选中部门的人员
|
||
const selectedEmployees = useMemo(() => {
|
||
if (!data) return []
|
||
if (selectedGroup === 'unclassified') return data.unclassified
|
||
if (selectedGroup === 'unbound') return []
|
||
if (!selectedPath) return []
|
||
return data.employees.filter((e) =>
|
||
personBelongsToOrganizationPath(e.departmentPaths?.length ? e.departmentPaths : [e.departmentPath], selectedPath),
|
||
)
|
||
}, [data, selectedPath, selectedGroup])
|
||
|
||
const employeePaths = (e: EmployeeRow) =>
|
||
e.departmentPaths?.length ? e.departmentPaths : e.departmentPath ? [e.departmentPath] : []
|
||
|
||
const descendantCount = selectedEmployees.filter(
|
||
(e) => !employeePaths(e).some((path) => path === selectedPath),
|
||
).length
|
||
|
||
const matchingDepartmentLabel = (e: EmployeeRow) => {
|
||
const matching = selectedPath
|
||
? employeePaths(e).filter((path) => personBelongsToOrganizationPath([path], selectedPath))
|
||
: employeePaths(e)
|
||
const names = (matching.length > 0 ? matching : employeePaths(e)).map(
|
||
(path) => lastOrganizationPathSegment(path) || path,
|
||
)
|
||
return names.filter(Boolean).join('、') || '—'
|
||
}
|
||
|
||
// 部门负责人计数(当前选中列表)
|
||
const leaderCount = selectedEmployees.filter((e) => e.isLeadership).length
|
||
|
||
// 等级分布(当前选中列表)
|
||
const levelCounts = useMemo(() => {
|
||
const map = new Map<string, number>()
|
||
for (const e of selectedEmployees) {
|
||
if (e.positionLevel) map.set(e.positionLevel, (map.get(e.positionLevel) ?? 0) + 1)
|
||
}
|
||
return Array.from(map.entries()).sort((a, b) => b[1] - a[1])
|
||
}, [selectedEmployees])
|
||
|
||
// 搜索过滤
|
||
const filteredEmployees = useMemo(() => {
|
||
if (!keyword.trim()) return selectedEmployees
|
||
const k = keyword.trim().toLowerCase()
|
||
return selectedEmployees.filter(
|
||
(e) =>
|
||
(e.displayName ?? '').toLowerCase().includes(k) ||
|
||
(e.realName ?? '').toLowerCase().includes(k) ||
|
||
(e.jobNumber ?? '').toLowerCase().includes(k) ||
|
||
(e.positionTitle ?? '').toLowerCase().includes(k),
|
||
)
|
||
}, [selectedEmployees, keyword])
|
||
|
||
const toggleExpand = (id: string) => {
|
||
setExpanded((prev) => {
|
||
const next = new Set(prev)
|
||
if (next.has(id)) next.delete(id)
|
||
else next.add(id)
|
||
return next
|
||
})
|
||
}
|
||
|
||
const selectNode = (node: OrgNode) => {
|
||
setSelectedPath(node.path)
|
||
setSelectedGroup('org')
|
||
if (!expanded.has(node.id) && node.children.length > 0) {
|
||
setExpanded((prev) => new Set(prev).add(node.id))
|
||
}
|
||
}
|
||
|
||
if (status === 'loading' || (status === 'authenticated' && !canAccess)) {
|
||
return <div className="min-h-screen bg-[#fafafa]" />
|
||
}
|
||
|
||
const content = (
|
||
<div className={treeAsSecondarySidebar ? 'w-full' : 'mx-auto max-w-[1400px] px-5 py-6'}>
|
||
{/* direction: cloud-quarry (seed 32cb33b5) — THESIS: 部门即层叠石料块,人员是块上铭牌 */}
|
||
{/* 页头 */}
|
||
<div className={`${treeAsSecondarySidebar ? 'border-b border-[#e2e8f0] bg-white px-5 py-5 sm:px-7' : 'mb-5'} flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between`}>
|
||
<div>
|
||
<h1 className="text-[26px] font-bold text-[#1a1c1d] tracking-tight">{heading}</h1>
|
||
<p className="text-[13px] text-[#86898e] mt-1">{description}</p>
|
||
</div>
|
||
<div className="flex w-full items-center justify-between gap-3 sm:w-auto sm:justify-end">
|
||
{data && (
|
||
<span className="text-[12px] text-[#86898e] sm:text-right">
|
||
共 <b className="text-[#1a1c1d]">{scopedEmployees.length}</b> 人
|
||
{isSuperAdmin && (
|
||
<span className="ml-2">
|
||
(在职 {scopedEmployees.filter((employee) => employee.employmentState !== 'RESIGNED').length} / 离职{' '}
|
||
{scopedEmployees.filter((employee) => employee.employmentState === 'RESIGNED').length})
|
||
</span>
|
||
)}
|
||
</span>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={() => setIncludeInactive((v) => !v)}
|
||
className={`shrink-0 whitespace-nowrap rounded border px-3 py-1.5 text-[12px] transition-colors ${
|
||
includeInactive
|
||
? 'bg-[#5a66b8] text-white border-[#5a66b8]'
|
||
: 'bg-white text-[#5a66b8] border-[#5a66b8] hover:bg-[#eef0fa]'
|
||
}`}
|
||
>
|
||
含离职
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className={`${treeAsSecondarySidebar ? 'mx-5 mt-4 sm:mx-7' : 'mb-4'} rounded-lg border border-[#e8c98a] bg-[#fdf6e9] px-4 py-3 text-[13px] text-[#a0671c]`}>
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{loading && !data ? (
|
||
<div className={`${treeAsSecondarySidebar ? 'm-5 sm:m-7' : ''} rounded-lg border border-[#e7e8ea] bg-white p-10 text-center text-sm text-[#86898e] shadow-sm`}>
|
||
加载中…
|
||
</div>
|
||
) : data ? (
|
||
<div className={`grid grid-cols-1 items-start ${treeAsSecondarySidebar ? 'gap-0 lg:grid-cols-[232px_minmax(0,1fr)] lg:items-stretch' : 'gap-5 lg:grid-cols-[300px_1fr]'}`}>
|
||
{/* 左侧:组织树 */}
|
||
<aside className={treeAsSecondarySidebar
|
||
? 'overflow-hidden border-b border-[#e2e8f0] bg-[#f8fafc] lg:min-h-0 lg:border-b-0 lg:border-r'
|
||
: 'overflow-hidden rounded-lg border border-[#e7e8ea] bg-white shadow-sm'}
|
||
aria-label={treeAsSecondarySidebar ? '人员架构二级导航' : undefined}
|
||
>
|
||
<div className={`${treeAsSecondarySidebar ? 'border-b border-[#e2e8f0] px-4 py-4' : 'border-b border-[#eef0f3] px-4 py-3'} flex items-center justify-between`}>
|
||
<span className={`${treeAsSecondarySidebar ? 'text-sm font-bold text-[#101828]' : 'text-[12px] font-semibold text-[#1a1c1d]'}`}>
|
||
{scopeRootName ? `${scopeRootName}组织架构` : '组织架构'}
|
||
</span>
|
||
<span className={`${treeAsSecondarySidebar ? 'text-xs text-[#66758b]' : 'text-[11px] text-[#86898e]'}`}>{scopedTree.length} 顶层节点</span>
|
||
</div>
|
||
<nav className={`${treeAsSecondarySidebar ? 'max-h-[42vh] p-2 lg:max-h-[calc(100dvh-188px)] lg:py-4' : 'max-h-[calc(100vh-260px)] py-2'} overflow-y-auto`} aria-label="组织树">
|
||
{scopeRootName && scopedTree.length === 0 && (
|
||
<p className="px-4 py-8 text-center text-[13px] leading-6 text-[#86898e]">
|
||
未找到“{scopeRootName}”组织节点
|
||
</p>
|
||
)}
|
||
{flatTree.map(({ node, depth }) => {
|
||
const hasChildren = node.children.length > 0
|
||
const isExpanded = expanded.has(node.id)
|
||
const isSelected = selectedGroup === 'org' && selectedPath === node.path
|
||
return (
|
||
<div key={node.id}>
|
||
<button
|
||
type="button"
|
||
onClick={() => selectNode(node)}
|
||
className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-colors ${
|
||
isSelected ? 'bg-[#eef0fa]' : 'hover:bg-[#f4f5f7]'
|
||
}`}
|
||
style={{ paddingLeft: `${12 + depth * 16}px` }}
|
||
>
|
||
{hasChildren ? (
|
||
<span
|
||
role="button"
|
||
tabIndex={-1}
|
||
aria-label={isExpanded ? '收起' : '展开'}
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
toggleExpand(node.id)
|
||
}}
|
||
className="shrink-0 cursor-pointer"
|
||
>
|
||
<svg
|
||
className={`w-3 h-3 text-[#86898e] transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
aria-hidden="true"
|
||
>
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
|
||
</svg>
|
||
</span>
|
||
) : (
|
||
<span className="w-3 inline-block" aria-hidden="true" />
|
||
)}
|
||
<span
|
||
className={`text-[13px] truncate ${
|
||
isSelected ? 'text-[#1a1c1d] font-semibold' : 'text-[#1a1c1d]'
|
||
}`}
|
||
>
|
||
{node.name}
|
||
</span>
|
||
<span className="ml-auto text-[11px] font-medium text-[#5a66b8] tabular-nums">
|
||
{node.count}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
)
|
||
})}
|
||
{/* 特殊组仅属于公司级全量视图。 */}
|
||
{!scopeRootName && <div className="border-t border-[#eef0f3] mt-2 pt-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setSelectedGroup('unclassified')
|
||
setSelectedPath(null)
|
||
}}
|
||
className={`w-full flex items-center justify-between px-3 py-2 text-left ${
|
||
selectedGroup === 'unclassified' ? 'bg-[#eef0fa]' : 'hover:bg-[#f4f5f7]'
|
||
}`}
|
||
>
|
||
<span className="text-[13px] text-[#a0671c]">未确定</span>
|
||
<span className="text-[11px] font-medium text-[#a0671c] tabular-nums">
|
||
{data.unclassified.length}
|
||
</span>
|
||
</button>
|
||
{isSuperAdmin && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setSelectedGroup('unbound')
|
||
setSelectedPath(null)
|
||
}}
|
||
className={`w-full flex items-center justify-between px-3 py-2 text-left ${
|
||
selectedGroup === 'unbound' ? 'bg-[#eef0fa]' : 'hover:bg-[#f4f5f7]'
|
||
}`}
|
||
>
|
||
<span className="text-[13px] text-[#a0671c]">未绑定账号</span>
|
||
<span className="text-[11px] font-medium text-[#a0671c] tabular-nums">
|
||
{data.unbound.length}
|
||
</span>
|
||
</button>
|
||
)}
|
||
</div>}
|
||
</nav>
|
||
</aside>
|
||
|
||
{/* 右侧:人员明细 */}
|
||
<section className={treeAsSecondarySidebar
|
||
? 'min-w-0 overflow-hidden bg-white'
|
||
: 'overflow-hidden rounded-lg border border-[#e7e8ea] bg-white shadow-sm'}
|
||
>
|
||
<div className="px-5 py-4 border-b border-[#eef0f3]">
|
||
<div className="flex items-center justify-between gap-4">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
{selectedGroup === 'org' && selectedPath ? (
|
||
<>
|
||
<span className="text-[11px] bg-[#5a66b8] text-white rounded px-2 py-0.5 font-semibold whitespace-nowrap">
|
||
L{selectedPath.split('/').length}
|
||
</span>
|
||
<span className="text-[14px] font-semibold text-[#1a1c1d] truncate">
|
||
{selectedPath.split('/').pop()}
|
||
</span>
|
||
</>
|
||
) : (
|
||
<span className="text-[14px] font-semibold text-[#1a1c1d]">
|
||
{selectedGroup === 'unclassified'
|
||
? '未确定部门'
|
||
: selectedGroup === 'unbound'
|
||
? '未绑定账号'
|
||
: scopeRootName ?? '请选择组织节点'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="relative shrink-0">
|
||
<input
|
||
value={keyword}
|
||
onChange={(e) => setKeyword(e.target.value)}
|
||
placeholder="搜索姓名 / 工号 / 职位"
|
||
className="w-40 md:w-52 text-[13px] px-3 py-1.5 rounded border border-[#d5dae0] bg-[#f4f5f7] focus:outline-none focus:border-[#5a66b8] focus:ring-2 focus:ring-[#eef0fa]"
|
||
/>
|
||
</div>
|
||
</div>
|
||
{selectedGroup === 'org' && selectedPath && (
|
||
<p className="text-[12px] text-[#86898e] mt-1.5 truncate">
|
||
{selectedPath}
|
||
{descendantCount > 0 ? ` · 含下级 ${descendantCount} 人` : ''}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* 统计行 */}
|
||
{selectedGroup === 'org' && (
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 px-5 py-4 border-b border-[#eef0f3] bg-[#f4f5f7]">
|
||
<div>
|
||
<div className="text-[22px] font-bold text-[#1a1c1d] tabular-nums leading-none">
|
||
{selectedEmployees.filter((e) => e.employmentState !== 'RESIGNED').length}
|
||
</div>
|
||
<div className="text-[10px] text-[#86898e] mt-1 tracking-wide">在职</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-[22px] font-bold text-[#1a1c1d] tabular-nums leading-none">
|
||
{leaderCount}
|
||
</div>
|
||
<div className="text-[10px] text-[#86898e] mt-1 tracking-wide">负责人</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-[22px] font-bold text-[#1a1c1d] tabular-nums leading-none">
|
||
{levelCounts[0]?.[0] ?? '—'}
|
||
</div>
|
||
<div className="text-[10px] text-[#86898e] mt-1 tracking-wide">最常见等级</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-[22px] font-bold text-[#1a1c1d] tabular-nums leading-none">
|
||
{selectedEmployees.filter((e) => e.managerName).length}
|
||
</div>
|
||
<div className="text-[10px] text-[#86898e] mt-1 tracking-wide">有主管记录</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 人员表 */}
|
||
{selectedGroup === 'unbound' ? (
|
||
<div className="p-5">
|
||
<p className="text-[13px] text-[#86898e] mb-3">
|
||
以下账号没有钉钉 ID,无法自动绑定权威人员档案,需人工核对后手动绑定。
|
||
</p>
|
||
<div className="grid gap-2">
|
||
{data.unbound.map((u) => (
|
||
<div
|
||
key={u.userId}
|
||
className="flex items-center justify-between px-4 py-2.5 rounded border border-[#eef0f3] bg-[#f4f5f7]"
|
||
>
|
||
<div>
|
||
<span className="text-[13px] text-[#1a1c1d] font-medium">
|
||
{u.realName ?? u.username}
|
||
</span>
|
||
<span className="text-[12px] text-[#86898e] ml-2">@{u.username}</span>
|
||
</div>
|
||
<span className="text-[12px] text-[#86898e]">{u.oldDepartment || '无部门'}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : filteredEmployees.length === 0 ? (
|
||
<div className="p-10 text-center text-[#86898e] text-sm">
|
||
{scopeRootName && !scopeRootNode
|
||
? `未找到“${scopeRootName}”组织节点,请先完成公司组织架构同步。`
|
||
: '该部门暂无人员'}
|
||
</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-left">
|
||
<thead>
|
||
<tr className="border-b border-[#e7e8ea] bg-[#f4f5f7]">
|
||
<th className="px-5 py-2.5 text-[10px] font-semibold text-[#86898e] tracking-wider">人员</th>
|
||
<th className="px-4 py-2.5 text-[10px] font-semibold text-[#86898e] tracking-wider">工号</th>
|
||
<th className="px-4 py-2.5 text-[10px] font-semibold text-[#86898e] tracking-wider">所属部门</th>
|
||
<th className="px-4 py-2.5 text-[10px] font-semibold text-[#86898e] tracking-wider">职位</th>
|
||
<th className="px-4 py-2.5 text-[10px] font-semibold text-[#86898e] tracking-wider">等级</th>
|
||
<th className="px-4 py-2.5 text-[10px] font-semibold text-[#86898e] tracking-wider">直属主管</th>
|
||
<th className="px-4 py-2.5 text-[10px] font-semibold text-[#86898e] tracking-wider">状态</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filteredEmployees.map((e) => (
|
||
<tr key={e.employeeProfileId} className="border-b border-[#eef0f3] hover:bg-[#f4f5f7]">
|
||
<td className="px-5 py-3">
|
||
<div className="flex items-center gap-2.5">
|
||
<span
|
||
className={`w-7 h-7 rounded-full text-[12px] font-semibold flex items-center justify-center ${
|
||
isManagementLevel(e)
|
||
? 'bg-[#fdf6e9] text-[#8a6114]'
|
||
: 'bg-[#eef0fa] text-[#1a1c1d]'
|
||
}`}
|
||
>
|
||
{(e.displayName ?? e.realName ?? '?').slice(0, 1)}
|
||
</span>
|
||
<span
|
||
className={`text-[13px] font-medium ${
|
||
isManagementLevel(e) ? 'text-[#8a6114]' : 'text-[#1a1c1d]'
|
||
}`}
|
||
>
|
||
{e.displayName ?? e.realName ?? e.username}
|
||
</span>
|
||
{e.isLeadership && (
|
||
<span className="text-[10px] text-[#1c8a5a] bg-[#e8f5ee] rounded px-1.5 py-0.5">
|
||
负责人
|
||
</span>
|
||
)}
|
||
{employeePaths(e).length > 1 && (
|
||
<span className="text-[10px] text-[#5a66b8] bg-[#eef0fa] rounded px-1.5 py-0.5">
|
||
兼任
|
||
</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3 text-[12px] text-[#86898e] tabular-nums">{e.jobNumber ?? '—'}</td>
|
||
<td className="px-4 py-3 text-[12px] text-[#86898e]" title={matchingDepartmentLabel(e)}>
|
||
{matchingDepartmentLabel(e)}
|
||
</td>
|
||
<td className="px-4 py-3 text-[13px] text-[#1a1c1d]">{e.positionTitle ?? '—'}</td>
|
||
<td className="px-4 py-3">
|
||
{e.positionLevel ? (
|
||
<span
|
||
className={`text-[11px] font-semibold rounded px-2 py-0.5 ${
|
||
e.levelType === 'M'
|
||
? 'bg-[#fdf6e9] text-[#a0671c]'
|
||
: 'bg-[#eef0fa] text-[#1a1c1d]'
|
||
}`}
|
||
>
|
||
{e.positionLevel}
|
||
{e.levelType ? ` · ${LEVEL_TYPE_LABEL[e.levelType] ?? e.levelType}` : ''}
|
||
</span>
|
||
) : (
|
||
<span className="text-[12px] text-[#b0b6be]">未记录</span>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-3 text-[13px] text-[#1a1c1d]">{e.managerName ?? '—'}</td>
|
||
<td className="px-4 py-3">
|
||
<span
|
||
className={`text-[11px] rounded-full px-2.5 py-0.5 ${
|
||
e.employmentState === 'RESIGNED'
|
||
? 'text-[#86898e] bg-[#eef0f3]'
|
||
: e.employmentState === 'UNKNOWN'
|
||
? 'text-[#a0671c] bg-[#fdf6e9]'
|
||
: 'text-[#1c8a5a] bg-[#e8f5ee]'
|
||
}`}
|
||
>
|
||
{STATE_LABEL[e.employmentState ?? 'UNKNOWN'] ?? '未知'}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)
|
||
|
||
if (embedded) {
|
||
return <main className="min-h-full bg-[#fafafa]">{content}</main>
|
||
}
|
||
|
||
return (
|
||
<BoardShell session={session} active="organization">
|
||
{content}
|
||
</BoardShell>
|
||
)
|
||
}
|