R38: 完成员工管理筛选与部门树试跑
这个提交包含在:
@@ -19,6 +19,11 @@ export type DepartmentListResponse = {
|
||||
tree: DepartmentTreeNode[];
|
||||
};
|
||||
|
||||
type DepartmentMutationResponse = {
|
||||
department: DepartmentItem;
|
||||
outcome?: "already_archived" | "archived";
|
||||
};
|
||||
|
||||
export async function fetchDepartments(params: { includeArchived?: boolean } = {}) {
|
||||
const search = new URLSearchParams();
|
||||
if (params.includeArchived != null) {
|
||||
@@ -30,23 +35,24 @@ export async function fetchDepartments(params: { includeArchived?: boolean } = {
|
||||
}
|
||||
|
||||
export async function createDepartment(payload: { name: string; parentId?: string | null }) {
|
||||
return apiRequestData<DepartmentItem>("/api/departments", {
|
||||
const response = await apiRequestData<DepartmentMutationResponse>("/api/departments", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return response.department;
|
||||
}
|
||||
|
||||
export async function updateDepartment(departmentId: string, payload: { name?: string; parentId?: string | null }) {
|
||||
return apiRequestData<DepartmentItem>(`/api/departments/${departmentId}`, {
|
||||
const response = await apiRequestData<DepartmentMutationResponse>(`/api/departments/${departmentId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return response.department;
|
||||
}
|
||||
|
||||
export async function archiveDepartment(departmentId: string) {
|
||||
return apiRequestData<DepartmentItem>(`/api/departments/${departmentId}/archive`, {
|
||||
return apiRequestData<DepartmentMutationResponse>(`/api/departments/${departmentId}/archive`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -101,12 +101,14 @@ export async function fetchEmployees(params: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: EmployeeStatus;
|
||||
departmentId?: string;
|
||||
q?: string;
|
||||
}) {
|
||||
const search = new URLSearchParams();
|
||||
search.set("page", String(params.page));
|
||||
search.set("pageSize", String(params.pageSize));
|
||||
if (params.status) search.set("status", params.status);
|
||||
if (params.departmentId) search.set("departmentId", params.departmentId);
|
||||
if (params.q) search.set("q", params.q);
|
||||
|
||||
return apiRequestData<EmployeeListResponse>(`/api/employees?${search.toString()}`);
|
||||
|
||||
+224
-24
@@ -7,10 +7,21 @@ import {
|
||||
archiveDepartment,
|
||||
createDepartment,
|
||||
fetchDepartments,
|
||||
updateDepartment,
|
||||
type DepartmentItem,
|
||||
type DepartmentTreeNode,
|
||||
} from "../api/departments";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../components/ui/dialog";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
@@ -18,6 +29,73 @@ import { toast } from "sonner";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
|
||||
function mapDepartmentErrorMessage(error: unknown, fallback: string) {
|
||||
if (!isApiError(error)) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
if (error.code === "VALID_001") {
|
||||
if (error.details?.parentId === "not_found") {
|
||||
return `${error.code}: 上级部门不存在。`;
|
||||
}
|
||||
if (error.details?.parentId === "archived") {
|
||||
return `${error.code}: 上级部门已归档,无法继续使用。`;
|
||||
}
|
||||
if (error.details?.parentId === "self_not_allowed") {
|
||||
return `${error.code}: 上级部门不能选择自身。`;
|
||||
}
|
||||
if (error.details?.parentId === "cycle_not_allowed") {
|
||||
return `${error.code}: 上级部门不能形成循环层级。`;
|
||||
}
|
||||
if (error.details?.status === "archived") {
|
||||
return `${error.code}: 已归档部门不可编辑。`;
|
||||
}
|
||||
if (error.details?.department === "has_children") {
|
||||
return `${error.code}: 当前部门存在下级节点,无法归档。`;
|
||||
}
|
||||
if (error.details?.department === "has_active_employees") {
|
||||
return `${error.code}: 当前部门仍有在职员工,无法归档。`;
|
||||
}
|
||||
}
|
||||
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
}
|
||||
|
||||
function flattenDepartmentTree(nodes: DepartmentTreeNode[], depth = 0): Array<{ id: string; label: string }> {
|
||||
const items: Array<{ id: string; label: string }> = [];
|
||||
for (const node of nodes) {
|
||||
items.push({ id: node.id, label: `${" ".repeat(depth)}${node.name}` });
|
||||
items.push(...flattenDepartmentTree(node.children, depth + 1));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function DepartmentTreeView({ nodes, depth = 0 }: { nodes: DepartmentTreeNode[]; depth?: number }) {
|
||||
if (!nodes.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{nodes.map((node) => (
|
||||
<div key={node.id} className="space-y-3">
|
||||
<div
|
||||
className="flex items-center gap-3 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2"
|
||||
style={{ marginLeft: `${depth * 20}px` }}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-zinc-900">{node.name}</div>
|
||||
<div className="text-xs text-zinc-500 font-mono">{node.id}</div>
|
||||
</div>
|
||||
{node.archivedAt ? <StatusBadge status="archived" /> : null}
|
||||
</div>
|
||||
{node.children.length ? <DepartmentTreeView nodes={node.children} depth={depth + 1} /> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DepartmentsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -29,6 +107,9 @@ export function DepartmentsPage() {
|
||||
const [parentId, setParentId] = useState<string>("");
|
||||
const [error, setError] = useState("");
|
||||
const [confirmArchive, setConfirmArchive] = useState<{ id: string; name: string } | null>(null);
|
||||
const [editDepartment, setEditDepartment] = useState<DepartmentItem | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editParentId, setEditParentId] = useState("");
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["departments", includeArchived],
|
||||
@@ -36,6 +117,7 @@ export function DepartmentsPage() {
|
||||
});
|
||||
|
||||
const departments = listQuery.data?.items ?? [];
|
||||
const departmentTree = listQuery.data?.tree ?? [];
|
||||
|
||||
const departmentById = useMemo(() => {
|
||||
const map = new Map<string, DepartmentItem>();
|
||||
@@ -45,6 +127,11 @@ export function DepartmentsPage() {
|
||||
return map;
|
||||
}, [departments]);
|
||||
|
||||
const departmentOptions = useMemo(
|
||||
() => flattenDepartmentTree(departmentTree).filter((item) => !departmentById.get(item.id)?.archivedAt),
|
||||
[departmentById, departmentTree],
|
||||
);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createDepartment({
|
||||
@@ -59,9 +146,32 @@ export function DepartmentsPage() {
|
||||
toast.success("部门已创建");
|
||||
},
|
||||
onError: (err) => {
|
||||
const msg = isApiError(err)
|
||||
? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`
|
||||
: err instanceof Error ? err.message : "创建部门失败";
|
||||
const msg = mapDepartmentErrorMessage(err, "创建部门失败");
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
},
|
||||
});
|
||||
|
||||
const editMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!editDepartment) {
|
||||
throw new Error("未选择待编辑部门");
|
||||
}
|
||||
return updateDepartment(editDepartment.id, {
|
||||
name: editName.trim(),
|
||||
parentId: editParentId ? editParentId : null,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["departments"] });
|
||||
setEditDepartment(null);
|
||||
setEditName("");
|
||||
setEditParentId("");
|
||||
setError("");
|
||||
toast.success("部门已更新");
|
||||
},
|
||||
onError: (err) => {
|
||||
const msg = mapDepartmentErrorMessage(err, "更新部门失败");
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
},
|
||||
@@ -75,9 +185,7 @@ export function DepartmentsPage() {
|
||||
toast.success("已归档");
|
||||
},
|
||||
onError: (err) => {
|
||||
const msg = isApiError(err)
|
||||
? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`
|
||||
: err instanceof Error ? err.message : "归档部门失败";
|
||||
const msg = mapDepartmentErrorMessage(err, "归档部门失败");
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
},
|
||||
@@ -140,13 +248,11 @@ export function DepartmentsPage() {
|
||||
onChange={(event) => setParentId(event.target.value)}
|
||||
>
|
||||
<option value="">无</option>
|
||||
{departments
|
||||
.filter((item) => !item.archivedAt)
|
||||
.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
{departmentOptions.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -163,6 +269,18 @@ export function DepartmentsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<h3 className="text-base font-semibold text-zinc-900">部门树形视图</h3>
|
||||
{listQuery.isLoading ? <TableSkeleton columns={1} rows={4} /> : null}
|
||||
{!listQuery.isLoading && !listQuery.isError ? (
|
||||
departmentTree.length ? (
|
||||
<DepartmentTreeView nodes={departmentTree} />
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">暂无部门层级</p>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<h3 className="text-base font-semibold text-zinc-900">部门列表</h3>
|
||||
|
||||
@@ -207,16 +325,30 @@ export function DepartmentsPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{!item.archivedAt ? (
|
||||
<Button
|
||||
data-testid="department-archive"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={archiveMutation.isPending}
|
||||
onClick={() => setConfirmArchive({ id: item.id, name: item.name })}
|
||||
>
|
||||
归档
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditDepartment(item);
|
||||
setEditName(item.name);
|
||||
setEditParentId(item.parentId ?? "");
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="department-archive"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={archiveMutation.isPending}
|
||||
onClick={() => setConfirmArchive({ id: item.id, name: item.name })}
|
||||
>
|
||||
归档
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-500">已归档</span>
|
||||
)}
|
||||
@@ -247,7 +379,75 @@ export function DepartmentsPage() {
|
||||
}}
|
||||
onOpenChange={(open) => { if (!open) setConfirmArchive(null); }}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(editDepartment)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditDepartment(null);
|
||||
setEditName("");
|
||||
setEditParentId("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑部门</DialogTitle>
|
||||
<DialogDescription>修改部门名称或上级部门,保存后列表和树形视图会同步刷新。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="editDeptName">部门名称</Label>
|
||||
<Input
|
||||
id="editDeptName"
|
||||
value={editName}
|
||||
onChange={(event) => setEditName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="editDeptParent">上级部门</Label>
|
||||
<select
|
||||
id="editDeptParent"
|
||||
className="select-std"
|
||||
value={editParentId}
|
||||
onChange={(event) => setEditParentId(event.target.value)}
|
||||
>
|
||||
<option value="">无</option>
|
||||
{departmentOptions
|
||||
.filter((item) => item.id !== editDepartment?.id)
|
||||
.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditDepartment(null);
|
||||
setEditName("");
|
||||
setEditParentId("");
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={editMutation.isPending || !editDepartment || !editName.trim()}
|
||||
onClick={() => editMutation.mutate()}
|
||||
>
|
||||
{editMutation.isPending ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+91
-27
@@ -7,6 +7,7 @@ import {
|
||||
startEmployeeOffboarding,
|
||||
startEmployeeOnboarding,
|
||||
} from "../api/employees";
|
||||
import { fetchDepartments } from "../api/departments";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
@@ -19,10 +20,24 @@ import { toast } from "sonner";
|
||||
export function PeoplePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [q, setQ] = useState("");
|
||||
const [status, setStatus] = useState<"" | "active" | "archived">("");
|
||||
const [departmentId, setDepartmentId] = useState("");
|
||||
|
||||
const departmentsQuery = useQuery({
|
||||
queryKey: ["departments", "people-page", "active"],
|
||||
queryFn: () => fetchDepartments({ includeArchived: false }),
|
||||
});
|
||||
|
||||
const employeesQuery = useQuery({
|
||||
queryKey: ["employees", "people-page", q],
|
||||
queryFn: () => fetchEmployees({ page: 1, pageSize: 100, q: q || undefined }),
|
||||
queryKey: ["employees", "people-page", q, status, departmentId],
|
||||
queryFn: () =>
|
||||
fetchEmployees({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
q: q || undefined,
|
||||
status: status || undefined,
|
||||
departmentId: departmentId || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const onboardingMutation = useMutation({
|
||||
@@ -74,6 +89,7 @@ export function PeoplePage() {
|
||||
});
|
||||
|
||||
const items = employeesQuery.data?.items ?? [];
|
||||
const departments = departmentsQuery.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -90,10 +106,48 @@ export function PeoplePage() {
|
||||
/>
|
||||
|
||||
<div className="card-std p-4">
|
||||
<Input placeholder="搜索姓名、工号、手机号、邮箱" value={q} onChange={(event) => setQ(event.target.value)} />
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
placeholder="搜索姓名、工号、手机号、邮箱"
|
||||
value={q}
|
||||
onChange={(event) => setQ(event.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="select-std w-auto"
|
||||
value={departmentId}
|
||||
onChange={(event) => setDepartmentId(event.target.value)}
|
||||
>
|
||||
<option value="">全部部门</option>
|
||||
{departments.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="select-std w-auto"
|
||||
value={status}
|
||||
onChange={(event) => setStatus(event.target.value as "" | "active" | "archived")}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="active">生效中</option>
|
||||
<option value="archived">已归档</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6">
|
||||
{employeesQuery.isError ? (
|
||||
<p className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{isApiError(employeesQuery.error)
|
||||
? `${employeesQuery.error.code}: ${mapApiErrorMessage(
|
||||
employeesQuery.error.code,
|
||||
employeesQuery.error.details,
|
||||
employeesQuery.error.message,
|
||||
)}`
|
||||
: "加载人员列表失败"}
|
||||
</p>
|
||||
) : null}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -104,32 +158,42 @@ export function PeoplePage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.name}</div>
|
||||
<div className="text-xs text-zinc-500">{item.employeeNo} · {item.email}</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.roleCodes.join(" / ") || "-"}</TableCell>
|
||||
<TableCell><StatusBadge status={item.status} /></TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild size="sm" variant="secondary" type="button">
|
||||
<Link to={`/people/${item.id}`}>查看档案</Link>
|
||||
</Button>
|
||||
<Button size="sm" type="button" disabled={onboardingMutation.isPending} onClick={() => onboardingMutation.mutate(item.id)}>
|
||||
发起入职
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" type="button" disabled={regularizeMutation.isPending} onClick={() => regularizeMutation.mutate(item.id)}>
|
||||
转正
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" type="button" disabled={offboardingMutation.isPending} onClick={() => offboardingMutation.mutate(item.id)}>
|
||||
发起离职
|
||||
</Button>
|
||||
</div>
|
||||
{items.length ? (
|
||||
items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.name}</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{item.employeeNo} · {item.email}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.roleCodes.join(" / ") || "-"}</TableCell>
|
||||
<TableCell><StatusBadge status={item.status} /></TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild size="sm" variant="secondary" type="button">
|
||||
<Link to={`/people/${item.id}`}>查看档案</Link>
|
||||
</Button>
|
||||
<Button size="sm" type="button" disabled={onboardingMutation.isPending} onClick={() => onboardingMutation.mutate(item.id)}>
|
||||
发起入职
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" type="button" disabled={regularizeMutation.isPending} onClick={() => regularizeMutation.mutate(item.id)}>
|
||||
转正
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" type="button" disabled={offboardingMutation.isPending} onClick={() => offboardingMutation.mutate(item.id)}>
|
||||
发起离职
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell className="py-3 text-zinc-500" colSpan={4}>
|
||||
暂无匹配员工
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
在新工单中引用
屏蔽一个用户