文件
hyb-oa/client/src/pages/OrganizationPage.tsx
T

231 行
8.3 KiB
TypeScript

import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
fetchEmployeeOrganization,
type OrganizationDepartmentSection,
type OrganizationTreeNode,
} from "../api/employees";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button";
function countNodes(nodes: OrganizationTreeNode[]): number {
return nodes.reduce((total, node) => total + 1 + countNodes(node.children), 0);
}
function countExternalManagers(nodes: OrganizationTreeNode[]): number {
return nodes.reduce(
(total, node) => total + (node.externalManager ? 1 : 0) + countExternalManagers(node.children),
0,
);
}
function OrganizationNodeCard({ node, depth = 0 }: { node: OrganizationTreeNode; depth?: number }) {
return (
<div className="space-y-3">
<div
className="finance-list-card"
style={{ marginLeft: `${Math.min(depth, 4) * 18}px` }}
>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-3">
<EmployeeAvatar name={node.name} src={node.avatarUrl} size="lg" />
<div>
<Link to={`/people/${node.id}`} className="text-base font-semibold text-zinc-900 hover:text-zinc-700">
{node.name}
</Link>
<div className="mt-1 text-sm text-zinc-500">
{node.employeeNo} · {node.title || "未设置职位"} · {node.department?.name ?? "-"}
</div>
</div>
</div>
{node.externalManager ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
跨部门汇报:{node.externalManager.department?.name ?? "未知部门"} · {node.externalManager.name}
</div>
) : null}
</div>
</div>
{node.children.length ? (
<div className="space-y-3">
{node.children.map((child) => (
<OrganizationNodeCard key={child.id} node={child} depth={depth + 1} />
))}
</div>
) : null}
</div>
);
}
export function OrganizationPage() {
const organizationQuery = useQuery({
queryKey: ["employee-organization"],
queryFn: fetchEmployeeOrganization,
});
const [expandedDepartments, setExpandedDepartments] = useState<Record<string, boolean>>({});
useEffect(() => {
if (!organizationQuery.data?.items.length) {
return;
}
setExpandedDepartments((current) => {
const next = { ...current };
for (const item of organizationQuery.data.items) {
const key = item.department?.id ?? item.department?.name ?? "unknown";
if (!(key in next)) {
next[key] = true;
}
}
return next;
});
}, [organizationQuery.data?.items]);
if (organizationQuery.isLoading) {
return (
<ListWorkspace>
<WorkspaceHeader
eyebrow="管理"
title="组织架构"
subtitle="按部门查看当前 active 员工的汇报关系。"
/>
<div className="admin-command-panel text-sm text-zinc-500">加载组织架构中...</div>
</ListWorkspace>
);
}
if (organizationQuery.isError) {
return (
<ListWorkspace>
<WorkspaceHeader
eyebrow="管理"
title="组织架构"
subtitle="按部门查看当前 active 员工的汇报关系。"
/>
<div className="admin-command-panel text-sm text-red-600">加载组织架构失败</div>
</ListWorkspace>
);
}
const sections = organizationQuery.data?.items ?? [];
const totalEmployees = sections.reduce((total, section) => total + countNodes(section.roots), 0);
const externalManagerCount = sections.reduce((total, section) => total + countExternalManagers(section.roots), 0);
const expandedCount = sections.filter((section) => {
const key = section.department?.id ?? section.department?.name ?? "unknown";
return expandedDepartments[key] ?? true;
}).length;
return (
<ListWorkspace>
<WorkspaceHeader
eyebrow="管理"
title="组织架构"
subtitle="按部门查看 active 员工的汇报关系,跨部门上级单独标注,便于组织复核。"
actions={
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
onClick={() =>
setExpandedDepartments(
Object.fromEntries(
sections.map((item) => [item.department?.id ?? item.department?.name ?? "unknown", true]),
),
)
}
>
全部展开
</Button>
<Button
type="button"
variant="secondary"
onClick={() =>
setExpandedDepartments(
Object.fromEntries(
sections.map((item) => [item.department?.id ?? item.department?.name ?? "unknown", false]),
),
)
}
>
全部折叠
</Button>
</div>
}
/>
<div className="finance-status-strip" data-testid="organization-summary">
<div className="finance-metric-card">
<div className="finance-metric-card__label">部门分组</div>
<div className="finance-metric-card__value">{sections.length}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">展示员工</div>
<div className="finance-metric-card__value">{totalEmployees}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">已展开</div>
<div className="finance-metric-card__value">{expandedCount}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">跨部门汇报</div>
<div className="finance-metric-card__value">{externalManagerCount}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">视图模式</div>
<div className="finance-metric-card__value text-base">只读</div>
</div>
</div>
{sections.length ? (
<div className="space-y-6">
{sections.map((section: OrganizationDepartmentSection) => {
const sectionKey = section.department?.id ?? section.department?.name ?? "unknown";
const expanded = expandedDepartments[sectionKey] ?? true;
const totalNodes = countNodes(section.roots);
return (
<section key={sectionKey} className="admin-command-panel">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-zinc-900">{section.department?.name ?? "未分配部门"}</h2>
<div className="mt-1 text-sm text-zinc-500">{totalNodes} 名员工 · {expanded ? "已展开" : "已折叠"}</div>
</div>
<Button
type="button"
variant="secondary"
onClick={() =>
setExpandedDepartments((current) => ({
...current,
[sectionKey]: !expanded,
}))
}
>
{expanded ? "折叠" : "展开"}
</Button>
</div>
{expanded ? (
<div className="mt-6 space-y-4">
{section.roots.length ? (
section.roots.map((root) => <OrganizationNodeCard key={root.id} node={root} />)
) : (
<div className="text-sm text-zinc-500">当前部门暂无可展示节点</div>
)}
</div>
) : null}
</section>
);
})}
</div>
) : (
<div className="admin-command-panel text-sm text-zinc-500">当前没有可展示的 active 员工。</div>
)}
</ListWorkspace>
);
}
export default OrganizationPage;