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

665 行
25 KiB
TypeScript

import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
batchDisableUsers,
batchEnableUsers,
batchSetEmployeesManager,
batchTransferEmployeesDepartment,
fetchEmployees,
fetchEmployeeOrganization,
regularizeEmployee,
startEmployeeOffboarding,
startEmployeeOnboarding,
type EmployeeEmploymentStatus,
type OrganizationTreeNode,
} from "../api/employees";
import { exportEmployees } from "../api/exports";
import { fetchDepartments } from "../api/departments";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../components/ui/dialog";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
import { Input } from "../components/ui/input";
import { StatusBadge } from "../components/common/StatusBadge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { downloadBlob } from "../lib/download";
import { toast } from "sonner";
const EMPLOYMENT_STATUS_OPTIONS: Array<{ value: EmployeeEmploymentStatus; label: string }> = [
{ value: "onboarding", label: "待入职" },
{ value: "probation", label: "试用期" },
{ value: "active", label: "在职" },
{ value: "offboarding", label: "离职办理中" },
{ value: "inactive", label: "已离职" },
];
function mapApiMessage(error: unknown, fallback: string) {
return isApiError(error)
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
: error instanceof Error
? error.message
: fallback;
}
function employmentStatusLabel(status: EmployeeEmploymentStatus | null) {
return EMPLOYMENT_STATUS_OPTIONS.find((item) => item.value === status)?.label ?? "-";
}
function collectOrganizationEmployees(nodes: OrganizationTreeNode[], target: Map<string, { id: string; name: string; employeeNo: string; departmentName: string }>) {
for (const node of nodes) {
if (!target.has(node.id)) {
target.set(node.id, {
id: node.id,
name: node.name,
employeeNo: node.employeeNo,
departmentName: node.department?.name ?? "未分配部门",
});
}
collectOrganizationEmployees(node.children, target);
}
}
export function PeoplePage() {
const { permissions } = useAuth();
const queryClient = useQueryClient();
const canManageUsers = hasPermission(permissions, "system.users");
const canViewStats = canManageUsers || hasPermission(permissions, "report.dashboard.view");
const [q, setQ] = useState("");
const [status, setStatus] = useState<"" | "active" | "archived">("");
const [departmentId, setDepartmentId] = useState("");
const [title, setTitle] = useState("");
const [managerId, setManagerId] = useState("");
const [hireDateFrom, setHireDateFrom] = useState("");
const [hireDateTo, setHireDateTo] = useState("");
const [employmentStatus, setEmploymentStatus] = useState<"" | EmployeeEmploymentStatus>("");
const [exportError, setExportError] = useState("");
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<string[]>([]);
const [transferDialogOpen, setTransferDialogOpen] = useState(false);
const [managerDialogOpen, setManagerDialogOpen] = useState(false);
const [enableConfirmOpen, setEnableConfirmOpen] = useState(false);
const [disableConfirmOpen, setDisableConfirmOpen] = useState(false);
const [targetDepartmentId, setTargetDepartmentId] = useState("");
const [targetManagerId, setTargetManagerId] = useState("");
const departmentsQuery = useQuery({
queryKey: ["departments", "people-page", "active"],
queryFn: () => fetchDepartments({ includeArchived: false }),
});
const organizationQuery = useQuery({
queryKey: ["employee-organization", "people-page"],
queryFn: fetchEmployeeOrganization,
});
const employeesQuery = useQuery({
queryKey: ["employees", "people-page", q, status, departmentId, title, managerId, hireDateFrom, hireDateTo, employmentStatus],
queryFn: () =>
fetchEmployees({
page: 1,
pageSize: 100,
q: q || undefined,
status: status || undefined,
departmentId: departmentId || undefined,
title: title || undefined,
managerId: managerId || undefined,
hireDateFrom: hireDateFrom || undefined,
hireDateTo: hireDateTo || undefined,
employmentStatus: employmentStatus || undefined,
}),
});
useEffect(() => {
setSelectedEmployeeIds([]);
}, [q, status, departmentId, title, managerId, hireDateFrom, hireDateTo, employmentStatus]);
async function invalidatePeopleQueries() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["employees"] }),
queryClient.invalidateQueries({ queryKey: ["employee-organization"] }),
queryClient.invalidateQueries({ queryKey: ["employee-stats"] }),
]);
}
const onboardingMutation = useMutation({
mutationFn: (employeeId: string) => startEmployeeOnboarding(employeeId),
onSuccess: async () => {
await invalidatePeopleQueries();
toast.success("已发起入职流程");
},
onError: (error) => {
toast.error(mapApiMessage(error, "发起入职失败"));
},
});
const regularizeMutation = useMutation({
mutationFn: (employeeId: string) => regularizeEmployee(employeeId),
onSuccess: async () => {
await invalidatePeopleQueries();
toast.success("已完成转正");
},
onError: (error) => {
toast.error(mapApiMessage(error, "转正失败"));
},
});
const offboardingMutation = useMutation({
mutationFn: (employeeId: string) => startEmployeeOffboarding(employeeId),
onSuccess: async () => {
await invalidatePeopleQueries();
toast.success("已发起离职流程");
},
onError: (error) => {
toast.error(mapApiMessage(error, "发起离职失败"));
},
});
const exportMutation = useMutation({
mutationFn: (format: "csv" | "xlsx") =>
exportEmployees({
format,
q: q || undefined,
status: status || undefined,
departmentId: departmentId || undefined,
title: title || undefined,
managerId: managerId || undefined,
hireDateFrom: hireDateFrom || undefined,
hireDateTo: hireDateTo || undefined,
employmentStatus: employmentStatus || undefined,
}),
onSuccess: (response, format) => {
downloadBlob(response.blob, response.filename ?? `employees.${format}`);
setExportError("");
toast.success("导出完成");
},
onError: (error) => {
const message = mapApiMessage(error, "导出失败");
setExportError(message);
toast.error(message);
},
});
const transferDepartmentMutation = useMutation({
mutationFn: () =>
batchTransferEmployeesDepartment({
employeeIds: selectedEmployeeIds,
departmentId: targetDepartmentId,
}),
onSuccess: async (result) => {
await invalidatePeopleQueries();
setSelectedEmployeeIds([]);
setTransferDialogOpen(false);
setTargetDepartmentId("");
toast.success(`已批量调部门 ${result.summary.updated} 人`);
},
onError: (error) => {
toast.error(mapApiMessage(error, "批量调部门失败"));
},
});
const setManagerMutation = useMutation({
mutationFn: () =>
batchSetEmployeesManager({
employeeIds: selectedEmployeeIds,
managerId: targetManagerId,
}),
onSuccess: async (result) => {
await invalidatePeopleQueries();
setSelectedEmployeeIds([]);
setManagerDialogOpen(false);
setTargetManagerId("");
toast.success(`已批量设置上级 ${result.summary.updated} 人`);
},
onError: (error) => {
toast.error(mapApiMessage(error, "批量设置上级失败"));
},
});
const batchEnableMutation = useMutation({
mutationFn: () => batchEnableUsers({ employeeIds: selectedEmployeeIds }),
onSuccess: async (result) => {
await invalidatePeopleQueries();
setSelectedEmployeeIds([]);
toast.success(`已批量启用 ${result.summary.updated} 人`);
},
onError: (error) => {
toast.error(mapApiMessage(error, "批量启用失败"));
},
});
const batchDisableMutation = useMutation({
mutationFn: () => batchDisableUsers({ employeeIds: selectedEmployeeIds }),
onSuccess: async (result) => {
await invalidatePeopleQueries();
setSelectedEmployeeIds([]);
toast.success(`已批量禁用 ${result.summary.updated} 人`);
},
onError: (error) => {
toast.error(mapApiMessage(error, "批量禁用失败"));
},
});
const items = employeesQuery.data?.items ?? [];
const departments = departmentsQuery.data?.items ?? [];
const managerMap = new Map<string, { id: string; name: string; employeeNo: string; departmentName: string }>();
for (const section of organizationQuery.data?.items ?? []) {
collectOrganizationEmployees(section.roots, managerMap);
}
const managerOptions = Array.from(managerMap.values()).sort(
(left, right) => left.departmentName.localeCompare(right.departmentName) || left.employeeNo.localeCompare(right.employeeNo),
);
const batchPending =
transferDepartmentMutation.isPending ||
setManagerMutation.isPending ||
batchEnableMutation.isPending ||
batchDisableMutation.isPending;
const tableColumnCount = canManageUsers ? 7 : 6;
function toggleSelection(employeeId: string, checked: boolean) {
setSelectedEmployeeIds((current) => {
if (!checked) {
return current.filter((item) => item !== employeeId);
}
if (current.includes(employeeId)) {
return current;
}
if (current.length >= 50) {
toast.error("单次最多选择 50 名员工");
return current;
}
return [...current, employeeId];
});
}
return (
<div className="space-y-6">
<PageHeader
title="人员管理"
subtitle="按当前员工档案与账号状态做筛选、批量处理和统计查看。"
actions={
<div className="flex flex-wrap gap-2">
{canManageUsers ? (
<Button asChild variant="secondary" type="button">
<Link to="/payroll">薪资管理</Link>
</Button>
) : null}
{canManageUsers ? (
<Button asChild variant="secondary" type="button">
<Link to="/performance">绩效考核</Link>
</Button>
) : null}
{canViewStats ? (
<Button asChild variant="secondary" type="button">
<Link to="/people/stats">员工统计</Link>
</Button>
) : null}
{canManageUsers ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" type="button" disabled={exportMutation.isPending}>
{exportMutation.isPending ? "导出中..." : "导出"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => exportMutation.mutate("csv")}>
导出 CSV
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => exportMutation.mutate("xlsx")}>
导出 Excel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
{canManageUsers ? (
<Button asChild variant="secondary" type="button">
<Link to="/system/users">先新建账号</Link>
</Button>
) : null}
</div>
}
/>
<div className="card-std space-y-4 p-4">
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
<Input
placeholder="搜索姓名、工号、手机号、邮箱"
value={q}
onChange={(event) => setQ(event.target.value)}
/>
<select
className="select-std w-full"
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-full"
value={status}
onChange={(event) => setStatus(event.target.value as "" | "active" | "archived")}
>
<option value="">全部账号状态</option>
<option value="active">生效中</option>
<option value="archived">已归档</option>
</select>
<Input
placeholder="按职位模糊筛选"
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
<select
className="select-std w-full"
value={managerId}
onChange={(event) => setManagerId(event.target.value)}
>
<option value="">全部直属上级</option>
{managerOptions.map((item) => (
<option key={item.id} value={item.id}>
{item.name} · {item.employeeNo} · {item.departmentName}
</option>
))}
</select>
<select
className="select-std w-full"
value={employmentStatus}
onChange={(event) => setEmploymentStatus(event.target.value as "" | EmployeeEmploymentStatus)}
>
<option value="">全部雇用状态</option>
{EMPLOYMENT_STATUS_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
<Input
type="date"
value={hireDateFrom}
onChange={(event) => setHireDateFrom(event.target.value)}
/>
<Input
type="date"
value={hireDateTo}
onChange={(event) => setHireDateTo(event.target.value)}
/>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-zinc-500">
<span>入职日期从</span>
<span>{hireDateFrom || "-"}</span>
<span>到</span>
<span>{hireDateTo || "-"}</span>
</div>
</div>
{canManageUsers && selectedEmployeeIds.length ? (
<div className="card-std flex flex-wrap items-center justify-between gap-3 p-4">
<div className="text-sm text-zinc-700">
已选 <span className="font-semibold text-zinc-900">{selectedEmployeeIds.length}</span> 名员工
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
disabled={batchPending}
onClick={() => {
setTargetDepartmentId("");
setTransferDialogOpen(true);
}}
>
批量调部门
</Button>
<Button
type="button"
variant="secondary"
disabled={batchPending}
onClick={() => {
setTargetManagerId("");
setManagerDialogOpen(true);
}}
>
批量设置上级
</Button>
<Button type="button" variant="secondary" disabled={batchPending} onClick={() => setEnableConfirmOpen(true)}>
批量启用
</Button>
<Button type="button" variant="secondary" disabled={batchPending} onClick={() => setDisableConfirmOpen(true)}>
批量禁用
</Button>
<Button type="button" variant="secondary" disabled={batchPending} onClick={() => setSelectedEmployeeIds([])}>
清空选择
</Button>
</div>
</div>
) : null}
<div className="card-std p-6">
{exportError ? (
<p className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{exportError}
</p>
) : null}
{employeesQuery.isError ? (
<p className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{mapApiMessage(employeesQuery.error, "加载人员列表失败")}
</p>
) : null}
<Table>
<TableHeader>
<TableRow>
{canManageUsers ? <TableHead>选择</TableHead> : null}
<TableHead>员工</TableHead>
<TableHead>部门 / 上级</TableHead>
<TableHead>岗位 / 雇用状态</TableHead>
<TableHead>角色</TableHead>
<TableHead>账号状态</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length ? (
items.map((item) => (
<TableRow key={item.id}>
{canManageUsers ? (
<TableCell>
<input
aria-label={`选择员工 ${item.name}`}
checked={selectedEmployeeIds.includes(item.id)}
className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400"
disabled={batchPending}
type="checkbox"
onChange={(event) => toggleSelection(item.id, event.target.checked)}
/>
</TableCell>
) : null}
<TableCell>
<div className="flex items-center gap-3">
<EmployeeAvatar name={item.name} src={item.avatarUrl} />
<div>
<div className="font-medium text-zinc-900">{item.name}</div>
<div className="text-xs text-zinc-500">
{item.employeeNo} · {item.email}
</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div className="text-zinc-900">{item.department?.name ?? "-"}</div>
<div className="text-xs text-zinc-500">
上级:{item.directManager ? `${item.directManager.name} · ${item.directManager.employeeNo}` : "-"}
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div className="text-zinc-900">{item.title || "-"}</div>
<div className="text-xs text-zinc-500">
{employmentStatusLabel(item.employmentStatus)} · 入职 {item.hireDate ?? "-"}
</div>
</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>
{canManageUsers ? (
<>
<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>
</>
) : null}
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="py-3 text-zinc-500" colSpan={tableColumnCount}>
暂无匹配员工
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<Dialog open={transferDialogOpen} onOpenChange={setTransferDialogOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>批量调部门</DialogTitle>
<DialogDescription>
将对已选 {selectedEmployeeIds.length} 名员工统一调部门,并写入 transfer 生命周期事件。
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700" htmlFor="people-batch-department">
目标部门
</label>
<select
id="people-batch-department"
className="select-std w-full"
value={targetDepartmentId}
onChange={(event) => setTargetDepartmentId(event.target.value)}
>
<option value="">请选择目标部门</option>
{departments.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setTransferDialogOpen(false)}>
取消
</Button>
<Button
type="button"
disabled={!targetDepartmentId || transferDepartmentMutation.isPending}
onClick={() => transferDepartmentMutation.mutate()}
>
{transferDepartmentMutation.isPending ? "处理中..." : "确认调部门"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={managerDialogOpen} onOpenChange={setManagerDialogOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>批量设置直属上级</DialogTitle>
<DialogDescription>
将对已选 {selectedEmployeeIds.length} 名员工统一设置直属上级,并写入 transfer 生命周期事件。
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700" htmlFor="people-batch-manager">
目标上级
</label>
<select
id="people-batch-manager"
className="select-std w-full"
value={targetManagerId}
onChange={(event) => setTargetManagerId(event.target.value)}
>
<option value="">请选择直属上级</option>
{managerOptions.map((item) => (
<option key={item.id} value={item.id}>
{item.name} · {item.employeeNo} · {item.departmentName}
</option>
))}
</select>
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setManagerDialogOpen(false)}>
取消
</Button>
<Button
type="button"
disabled={!targetManagerId || setManagerMutation.isPending}
onClick={() => setManagerMutation.mutate()}
>
{setManagerMutation.isPending ? "处理中..." : "确认设置"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
open={enableConfirmOpen}
onOpenChange={setEnableConfirmOpen}
title="批量启用账号"
description={`将统一启用已选 ${selectedEmployeeIds.length} 名员工账号,并吊销当前登录会话。`}
confirmLabel={batchEnableMutation.isPending ? "处理中..." : "确认启用"}
onConfirm={async () => {
await batchEnableMutation.mutateAsync();
}}
/>
<ConfirmDialog
open={disableConfirmOpen}
onOpenChange={setDisableConfirmOpen}
title="批量禁用账号"
description={`将统一禁用已选 ${selectedEmployeeIds.length} 名员工账号,并立即吊销当前登录会话。`}
confirmLabel={batchDisableMutation.isPending ? "处理中..." : "确认禁用"}
variant="destructive"
onConfirm={async () => {
await batchDisableMutation.mutateAsync();
}}
/>
</div>
);
}
export default PeoplePage;