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) { 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([]); 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(); 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 (
{canManageUsers ? ( ) : null} {canManageUsers ? ( ) : null} {canViewStats ? ( ) : null} {canManageUsers ? ( exportMutation.mutate("csv")}> 导出 CSV exportMutation.mutate("xlsx")}> 导出 Excel ) : null} {canManageUsers ? ( ) : null}
} />
setQ(event.target.value)} /> setTitle(event.target.value)} /> setHireDateFrom(event.target.value)} /> setHireDateTo(event.target.value)} />
入职日期从 {hireDateFrom || "-"} 到 {hireDateTo || "-"}
{canManageUsers && selectedEmployeeIds.length ? (
已选 {selectedEmployeeIds.length} 名员工
) : null}
{exportError ? (

{exportError}

) : null} {employeesQuery.isError ? (

{mapApiMessage(employeesQuery.error, "加载人员列表失败")}

) : null} {canManageUsers ? 选择 : null} 员工 部门 / 上级 岗位 / 雇用状态 角色 账号状态 操作 {items.length ? ( items.map((item) => ( {canManageUsers ? ( toggleSelection(item.id, event.target.checked)} /> ) : null}
{item.name}
{item.employeeNo} · {item.email}
{item.department?.name ?? "-"}
上级:{item.directManager ? `${item.directManager.name} · ${item.directManager.employeeNo}` : "-"}
{item.title || "-"}
{employmentStatusLabel(item.employmentStatus)} · 入职 {item.hireDate ?? "-"}
{item.roleCodes.join(" / ") || "-"}
{canManageUsers ? ( <> ) : null}
)) ) : ( 暂无匹配员工 )}
批量调部门 将对已选 {selectedEmployeeIds.length} 名员工统一调部门,并写入 transfer 生命周期事件。
批量设置直属上级 将对已选 {selectedEmployeeIds.length} 名员工统一设置直属上级,并写入 transfer 生命周期事件。
{ await batchEnableMutation.mutateAsync(); }} /> { await batchDisableMutation.mutateAsync(); }} /> ); } export default PeoplePage;