/* * 方向契约(云采石场 · 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 = { M: '管理', P: '专业', S: '支持', } function isManagementLevel(employee: EmployeeRow) { if (employee.levelType === 'M') return true return /^M\d+/i.test(employee.positionLevel || '') } const STATE_LABEL: Record = { ACTIVE: '在职', RESIGNED: '离职', UNKNOWN: '未知', } type OrganizationWorkspaceProps = { embedded?: boolean scopeRootName?: string heading?: string description?: string } 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 自动同步)', }: 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(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [includeInactive, setIncludeInactive] = useState(false) const [expanded, setExpanded] = useState>(new Set()) const [selectedPath, setSelectedPath] = useState(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() 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
} const content = (
{/* direction: cloud-quarry (seed 32cb33b5) — THESIS: 部门即层叠石料块,人员是块上铭牌 */} {/* 页头 */}

{heading}

{description}

{data && ( 共 {scopedEmployees.length} 人 {isSuperAdmin && ( (在职 {scopedEmployees.filter((employee) => employee.employmentState !== 'RESIGNED').length} / 离职{' '} {scopedEmployees.filter((employee) => employee.employmentState === 'RESIGNED').length}) )} )}
{error && (
{error}
)} {loading && !data ? (
加载中…
) : data ? (
{/* 左侧:组织树 */} {/* 右侧:人员明细 */}
{selectedGroup === 'org' && selectedPath ? ( <> L{selectedPath.split('/').length} {selectedPath.split('/').pop()} ) : ( {selectedGroup === 'unclassified' ? '未确定部门' : selectedGroup === 'unbound' ? '未绑定账号' : scopeRootName ?? '请选择组织节点'} )}
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]" />
{selectedGroup === 'org' && selectedPath && (

{selectedPath} {descendantCount > 0 ? ` · 含下级 ${descendantCount} 人` : ''}

)}
{/* 统计行 */} {selectedGroup === 'org' && (
{selectedEmployees.filter((e) => e.employmentState !== 'RESIGNED').length}
在职
{leaderCount}
负责人
{levelCounts[0]?.[0] ?? '—'}
最常见等级
{selectedEmployees.filter((e) => e.managerName).length}
有主管记录
)} {/* 人员表 */} {selectedGroup === 'unbound' ? (

以下账号没有钉钉 ID,无法自动绑定权威人员档案,需人工核对后手动绑定。

{data.unbound.map((u) => (
{u.realName ?? u.username} @{u.username}
{u.oldDepartment || '无部门'}
))}
) : filteredEmployees.length === 0 ? (
{scopeRootName && !scopeRootNode ? `未找到“${scopeRootName}”组织节点,请先完成公司组织架构同步。` : '该部门暂无人员'}
) : (
{filteredEmployees.map((e) => ( ))}
人员 工号 所属部门 职位 等级 直属主管 状态
{(e.displayName ?? e.realName ?? '?').slice(0, 1)} {e.displayName ?? e.realName ?? e.username} {e.isLeadership && ( 负责人 )} {employeePaths(e).length > 1 && ( 兼任 )}
{e.jobNumber ?? '—'} {matchingDepartmentLabel(e)} {e.positionTitle ?? '—'} {e.positionLevel ? ( {e.positionLevel} {e.levelType ? ` · ${LEVEL_TYPE_LABEL[e.levelType] ?? e.levelType}` : ''} ) : ( 未记录 )} {e.managerName ?? '—'} {STATE_LABEL[e.employmentState ?? 'UNKNOWN'] ?? '未知'}
)}
) : null}
) if (embedded) { return
{content}
} return ( {content} ) }