A1/A3/A5/A7/A8 完成员工头像前端、组织架构页与QA回填
这个提交包含在:
@@ -0,0 +1,175 @@
|
||||
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 { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
|
||||
function countNodes(nodes: OrganizationTreeNode[]): number {
|
||||
return nodes.reduce((total, node) => total + 1 + countNodes(node.children), 0);
|
||||
}
|
||||
|
||||
function OrganizationNodeCard({ node, depth = 0 }: { node: OrganizationTreeNode; depth?: number }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
className="rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm"
|
||||
style={{ marginLeft: `${depth * 24}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 <div className="card-std p-6 text-sm text-zinc-500">加载组织架构中...</div>;
|
||||
}
|
||||
|
||||
if (organizationQuery.isError) {
|
||||
return <div className="card-std p-6 text-sm text-red-600">加载组织架构失败</div>;
|
||||
}
|
||||
|
||||
const sections = organizationQuery.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
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>
|
||||
}
|
||||
/>
|
||||
|
||||
{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="card-std p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-zinc-900">{section.department?.name ?? "未分配部门"}</h3>
|
||||
<div className="mt-1 text-sm text-zinc-500">{totalNodes} 名员工</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="card-std p-6 text-sm text-zinc-500">当前没有可展示的 active 员工。</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrganizationPage;
|
||||
在新工单中引用
屏蔽一个用户