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, fetchWecomHrMasterSyncRuns, regularizeEmployee, retryEmployeeWecomDirectorySync, runWecomHrMasterSync, startEmployeeOffboarding, startEmployeeOnboarding, type EmployeeEmploymentStatus, type EmployeeSummary, type WecomHrMasterSyncRun, type WecomDirectoryState, 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 { DateTextInput } from "../components/common/DateTextInput"; 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 wecomDirectoryStatusView(state?: WecomDirectoryState) { const errorCode = state?.latestSync?.errorCode ?? ""; if (!state) { return { label: "未同步", status: "not_triggered", hint: "无记录" }; } if (state.status === "synced" || state.status === "sync_success") { return { label: "同步成功", status: "succeeded", hint: state.externalUserId ?? "已绑定" }; } if (state.status === "bound") { return { label: "已绑定", status: "bound", hint: state.externalUserId ?? "已绑定" }; } if (state.status === "sync_failed") { return { label: "同步失败", status: "failed", hint: state.latestSync?.errorCode ?? "待重试" }; } if (state.status === "sync_skipped") { return { label: errorCode === "wecom_contact_config_missing" || errorCode === "wecom_contact_sync_disabled" ? "配置缺失" : "已跳过", status: "skipped", hint: state.latestSync?.errorCode ?? "未执行", }; } return { label: "未绑定", status: "not_triggered", hint: "待创建" }; } function shouldShowWecomRetry(state?: WecomDirectoryState) { return !state || ["unbound", "sync_failed", "sync_skipped"].includes(state.status); } function wecomHrMasterRunStatusLabel(status: WecomHrMasterSyncRun["status"]) { const map: Record = { running: "执行中", success: "已完成", partial: "部分完成", failed: "失败", skipped: "已跳过", }; return map[status] ?? status; } function wecomHrMasterModeLabel(mode: WecomHrMasterSyncRun["mode"]) { return mode === "apply" ? "写入" : "预检"; } function formatSyncSummary(run?: WecomHrMasterSyncRun | null) { const summary = run?.summary; if (!summary) return "暂无统计"; return [ `总数 ${summary.total ?? 0}`, `新建 ${summary.created ?? 0}`, `更新 ${summary.updated ?? 0}`, `跳过 ${summary.skipped ?? 0}`, `冲突 ${summary.conflicts ?? 0}`, `失败 ${summary.failed ?? 0}`, ].join(" / "); } 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 [wecomHrApplyConfirmOpen, setWecomHrApplyConfirmOpen] = useState(false); const [offboardingTarget, setOffboardingTarget] = useState(null); 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, }), }); const wecomHrMasterRunsQuery = useQuery({ queryKey: ["wecom-hr-master-sync", "runs"], queryFn: () => fetchWecomHrMasterSyncRuns(5), enabled: canManageUsers, retry: false, }); 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(); setOffboardingTarget(null); toast.success("已发起离职流程"); }, onError: (error) => { toast.error(mapApiMessage(error, "发起离职失败")); }, }); const wecomDirectoryRetryMutation = useMutation({ mutationFn: (employeeId: string) => retryEmployeeWecomDirectorySync(employeeId), onSuccess: async (result) => { await invalidatePeopleQueries(); const code = result.result?.code; if (result.result?.skipped) { toast.warning(code ? `企微同步已跳过:${code}` : "企微同步已跳过"); return; } if (result.result?.status === "failed") { toast.error(code ? `企微同步失败:${code}` : "企微同步失败"); return; } toast.success("企微同步已提交"); }, onError: (error) => { toast.error(mapApiMessage(error, "企微同步重试失败")); }, }); const wecomHrDryRunMutation = useMutation({ mutationFn: () => runWecomHrMasterSync({ mode: "dry_run", limit: 50 }), onSuccess: async (run) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["wecom-hr-master-sync"] }), invalidatePeopleQueries(), ]); toast.success(`企微 HR 主数据预检完成:${formatSyncSummary(run)}`); }, onError: (error) => { toast.error(mapApiMessage(error, "企微 HR 主数据预检失败")); }, }); const wecomHrApplyMutation = useMutation({ mutationFn: () => runWecomHrMasterSync({ mode: "apply", limit: 50 }), onSuccess: async (run) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["wecom-hr-master-sync"] }), invalidatePeopleQueries(), ]); setWecomHrApplyConfirmOpen(false); toast.success(`企微 HR 主数据写入完成:${formatSyncSummary(run)}`); }, onError: (error) => { toast.error(mapApiMessage(error, "企微 HR 主数据写入失败")); }, }); 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 || wecomDirectoryRetryMutation.isPending || wecomHrDryRunMutation.isPending || wecomHrApplyMutation.isPending; const tableColumnCount = canManageUsers ? 8 : 7; const latestWecomHrRun = wecomHrMasterRunsQuery.data?.items?.[0] ?? null; const wecomHrMasterError = wecomHrMasterRunsQuery.isError ? mapApiMessage(wecomHrMasterRunsQuery.error, "企微 HR 主数据同步未就绪") : ""; 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}
} /> {canManageUsers ? (

企微 HR 主数据镜像

{latestWecomHrRun ? ( ) : null}
{latestWecomHrRun ? `${formatSyncSummary(latestWecomHrRun)} · ${latestWecomHrRun.finishedAt ?? latestWecomHrRun.createdAt ?? "-"}` : "企微/人事助手作为人员主源;OA 保存镜像、账号、权限和审计。"}
{wecomHrMasterError ? (
{wecomHrMasterError}
) : null} {latestWecomHrRun?.items?.length ? (
最近失败项:{latestWecomHrRun.items.filter((item) => item.status === "failed" || item.status === "conflict").length}
) : null}
) : 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) => { const wecomView = wecomDirectoryStatusView(item.wecomDirectory); return ( {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(" / ") || "-"}
{wecomView.hint}
{canManageUsers ? ( <> {shouldShowWecomRetry(item.wecomDirectory) ? ( ) : null} ) : null}
); }) ) : ( 暂无匹配员工 )}
批量调部门 将对已选 {selectedEmployeeIds.length} 名员工统一调部门,并写入 transfer 生命周期事件。
批量设置直属上级 将对已选 {selectedEmployeeIds.length} 名员工统一设置直属上级,并写入 transfer 生命周期事件。
{ await batchEnableMutation.mutateAsync(); }} /> { await batchDisableMutation.mutateAsync(); }} /> { await wecomHrApplyMutation.mutateAsync(); }} /> { if (!open) setOffboardingTarget(null); }} title="发起离职流程" description={ offboardingTarget ? `将为 ${offboardingTarget.name} 发起离职清单;清单完成后会禁用 OA 账号并尝试禁用企业微信成员。` : "" } confirmLabel={offboardingMutation.isPending ? "处理中..." : "确认发起"} variant="destructive" onConfirm={async () => { if (offboardingTarget) { await offboardingMutation.mutateAsync(offboardingTarget.id); } }} /> ); } export default PeoplePage;