R44 A2-A8 完成员工高级筛选批量操作与统计页

这个提交包含在:
2026-03-29 12:27:44 +08:00
父节点 c3873999c2
当前提交 d11f5593d1
共修改 17 个文件,包含 1640 行新增和 337 行删除
+101 -3
查看文件
@@ -1,6 +1,7 @@
import { apiRequestData } from "./http";
export type EmployeeStatus = "active" | "archived";
export type EmployeeEmploymentStatus = "onboarding" | "probation" | "active" | "offboarding" | "inactive";
export type EmployeeContractType = "labor" | "nda" | "non_compete" | "internship";
export type EmployeeContractStatus = "active" | "expired" | "terminated";
@@ -17,8 +18,12 @@ export type EmployeeSummary = {
email: string;
status: EmployeeStatus;
departmentId: string;
department: EmployeeDepartmentSummary | null;
directManagerId: string | null;
directManager: { id: string; employeeNo: string; name: string } | null;
title: string;
employmentStatus: EmployeeEmploymentStatus | null;
hireDate: string | null;
avatarUrl: string | null;
roleCodes: string[];
createdAt: string | null;
@@ -119,7 +124,7 @@ export type EmployeeProfile = {
phone: string;
email: string;
status: EmployeeStatus;
employmentStatus: "onboarding" | "probation" | "active" | "offboarding" | "inactive";
employmentStatus: EmployeeEmploymentStatus;
departmentId: string;
title: string;
directManagerId: string | null;
@@ -158,19 +163,80 @@ export type EmployeeProfile = {
};
};
export async function fetchEmployees(params: {
export type FetchEmployeesParams = {
page: number;
pageSize: number;
status?: EmployeeStatus;
departmentId?: string;
q?: string;
}) {
title?: string;
managerId?: string;
hireDateFrom?: string;
hireDateTo?: string;
employmentStatus?: EmployeeEmploymentStatus;
};
export type BatchOperationResult = {
summary: {
total: number;
updated: number;
};
employeeIds: string[];
};
export type EmployeeStatsResponse = {
asOfDate: string;
employmentStatusSummary: {
total: number;
items: Array<{
status: EmployeeEmploymentStatus;
count: number;
}>;
};
departmentHeadcount: Array<{
departmentId: string;
departmentName: string;
count: number;
}>;
recentMovements: {
dateFrom: string;
dateTo: string;
hires: number;
departures: number;
};
expiringContracts: {
dateFrom: string;
dateTo: string;
count: number;
};
};
export type BatchTransferDepartmentPayload = {
employeeIds: string[];
departmentId: string;
};
export type BatchToggleUsersPayload = {
employeeIds: string[];
};
export type BatchSetManagerPayload = {
employeeIds: string[];
managerId: string;
};
export async function fetchEmployees(params: FetchEmployeesParams) {
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);
if (params.title) search.set("title", params.title);
if (params.managerId) search.set("managerId", params.managerId);
if (params.hireDateFrom) search.set("hireDateFrom", params.hireDateFrom);
if (params.hireDateTo) search.set("hireDateTo", params.hireDateTo);
if (params.employmentStatus) search.set("employmentStatus", params.employmentStatus);
return apiRequestData<EmployeeListResponse>(`/api/employees?${search.toString()}`);
}
@@ -264,6 +330,10 @@ export async function fetchEmployeeOrganization() {
return apiRequestData<{ items: OrganizationDepartmentSection[] }>("/api/employees/organization");
}
export async function fetchEmployeeStats() {
return apiRequestData<EmployeeStatsResponse>("/api/employees/stats");
}
export async function fetchEmployeeContracts(employeeId: string) {
return apiRequestData<{ items: EmployeeContract[] }>(`/api/employees/${employeeId}/contracts`);
}
@@ -316,3 +386,31 @@ export async function skipEmployeeChecklistTask(employeeId: string, taskId: stri
body: JSON.stringify({}),
});
}
export async function batchTransferEmployeesDepartment(payload: BatchTransferDepartmentPayload) {
return apiRequestData<BatchOperationResult>("/api/users/batch/transfer-department", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function batchEnableUsers(payload: BatchToggleUsersPayload) {
return apiRequestData<BatchOperationResult>("/api/users/batch/enable", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function batchDisableUsers(payload: BatchToggleUsersPayload) {
return apiRequestData<BatchOperationResult>("/api/users/batch/disable", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function batchSetEmployeesManager(payload: BatchSetManagerPayload) {
return apiRequestData<BatchOperationResult>("/api/users/batch/set-manager", {
method: "POST",
body: JSON.stringify(payload),
});
}
+10
查看文件
@@ -61,6 +61,11 @@ export async function exportEmployees(params: {
status?: "active" | "archived";
departmentId?: string;
q?: string;
title?: string;
managerId?: string;
hireDateFrom?: string;
hireDateTo?: string;
employmentStatus?: "onboarding" | "probation" | "active" | "offboarding" | "inactive";
limit?: number;
}) {
const qs = new URLSearchParams();
@@ -68,6 +73,11 @@ export async function exportEmployees(params: {
if (params.status) qs.set("status", params.status);
if (params.departmentId) qs.set("departmentId", params.departmentId);
if (params.q) qs.set("q", params.q);
if (params.title) qs.set("title", params.title);
if (params.managerId) qs.set("managerId", params.managerId);
if (params.hireDateFrom) qs.set("hireDateFrom", params.hireDateFrom);
if (params.hireDateTo) qs.set("hireDateTo", params.hireDateTo);
if (params.employmentStatus) qs.set("employmentStatus", params.employmentStatus);
if (typeof params.limit === "number") qs.set("limit", String(params.limit));
return apiRequestBlob(`/api/exports/employees?${qs.toString()}`);
+450 -60
查看文件
@@ -1,11 +1,18 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useState } from "react";
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";
@@ -13,32 +20,94 @@ import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { downloadBlob } from "../lib/download";
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
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 canExportEmployees = hasPermission(permissions, "system.users");
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],
queryKey: ["employees", "people-page", q, status, departmentId, title, managerId, hireDateFrom, hireDateTo, employmentStatus],
queryFn: () =>
fetchEmployees({
page: 1,
@@ -46,54 +115,56 @@ export function PeoplePage() {
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 queryClient.invalidateQueries({ queryKey: ["employees"] });
await invalidatePeopleQueries();
toast.success("已发起入职流程");
},
onError: (error) => {
const message = isApiError(error)
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
: error instanceof Error
? error.message
: "发起入职失败";
toast.error(message);
toast.error(mapApiMessage(error, "发起入职失败"));
},
});
const regularizeMutation = useMutation({
mutationFn: (employeeId: string) => regularizeEmployee(employeeId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["employees"] });
await invalidatePeopleQueries();
toast.success("已完成转正");
},
onError: (error) => {
const message = isApiError(error)
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
: error instanceof Error
? error.message
: "转正失败";
toast.error(message);
toast.error(mapApiMessage(error, "转正失败"));
},
});
const offboardingMutation = useMutation({
mutationFn: (employeeId: string) => startEmployeeOffboarding(employeeId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["employees"] });
await invalidatePeopleQueries();
toast.success("已发起离职流程");
},
onError: (error) => {
const message = isApiError(error)
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
: error instanceof Error
? error.message
: "发起离职失败";
toast.error(message);
toast.error(mapApiMessage(error, "发起离职失败"));
},
});
@@ -104,6 +175,11 @@ export function PeoplePage() {
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}`);
@@ -111,27 +187,117 @@ export function PeoplePage() {
toast.success("导出完成");
},
onError: (error) => {
const message = isApiError(error)
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
: error instanceof Error
? error.message
: "导出失败";
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="小团队用这里管入职、转正、离职与档案入口。"
subtitle="按当前员工档案与账号状态做筛选、批量处理和统计查看。"
actions={
<div className="flex gap-2">
{canExportEmployees ? (
<div className="flex flex-wrap gap-2">
{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}>
@@ -148,22 +314,24 @@ export function PeoplePage() {
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button asChild variant="secondary" type="button">
<Link to="/system/users">先新建账号</Link>
</Button>
{canManageUsers ? (
<Button asChild variant="secondary" type="button">
<Link to="/system/users">先新建账号</Link>
</Button>
) : null}
</div>
}
/>
<div className="card-std p-4">
<div className="flex flex-wrap items-center gap-3">
<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-auto"
className="select-std w-full"
value={departmentId}
onChange={(event) => setDepartmentId(event.target.value)}
>
@@ -175,17 +343,103 @@ export function PeoplePage() {
))}
</select>
<select
className="select-std w-auto"
className="select-std w-full"
value={status}
onChange={(event) => setStatus(event.target.value as "" | "active" | "archived")}
>
<option value="">全部状态</option>
<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">
@@ -194,19 +448,16 @@ export function PeoplePage() {
) : 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">
{isApiError(employeesQuery.error)
? `${employeesQuery.error.code}: ${mapApiErrorMessage(
employeesQuery.error.code,
employeesQuery.error.details,
employeesQuery.error.message,
)}`
: "加载人员列表失败"}
{mapApiMessage(employeesQuery.error, "加载人员列表失败")}
</p>
) : null}
<Table>
<TableHeader>
<TableRow>
{canManageUsers ? <TableHead>选择</TableHead> : null}
<TableHead>员工</TableHead>
<TableHead>部门 / 上级</TableHead>
<TableHead>岗位 / 雇用状态</TableHead>
<TableHead>角色</TableHead>
<TableHead>账号状态</TableHead>
<TableHead>操作</TableHead>
@@ -216,6 +467,18 @@ export function PeoplePage() {
{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} />
@@ -227,29 +490,51 @@ export function PeoplePage() {
</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>
<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>
{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={4}>
<TableCell className="py-3 text-zinc-500" colSpan={tableColumnCount}>
暂无匹配员工
</TableCell>
</TableRow>
@@ -257,6 +542,111 @@ export function PeoplePage() {
</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>
);
}
+121
查看文件
@@ -0,0 +1,121 @@
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { fetchEmployeeStats, type EmployeeEmploymentStatus } from "../api/employees";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
const EMPLOYMENT_STATUS_LABELS: Record<EmployeeEmploymentStatus, string> = {
onboarding: "待入职",
probation: "试用期",
active: "在职",
offboarding: "离职办理中",
inactive: "已离职",
};
function employmentStatusLabel(status: EmployeeEmploymentStatus) {
return EMPLOYMENT_STATUS_LABELS[status] ?? status;
}
export function PeopleStatsPage() {
const statsQuery = useQuery({
queryKey: ["employee-stats"],
queryFn: fetchEmployeeStats,
});
if (statsQuery.isLoading) {
return <div className="card-std p-6 text-sm text-zinc-500">加载员工统计中...</div>;
}
if (statsQuery.isError || !statsQuery.data) {
return <div className="card-std p-6 text-sm text-red-600">加载员工统计失败</div>;
}
const stats = statsQuery.data;
return (
<div className="space-y-6">
<PageHeader
title="员工统计"
subtitle={`统计口径截至 ${stats.asOfDate},仅展示聚合数据。`}
actions={
<Button asChild variant="secondary" type="button">
<Link to="/people">返回人员管理</Link>
</Button>
}
/>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<section className="card-std p-5">
<div className="text-sm text-zinc-500">在职人数</div>
<div className="mt-2 text-3xl font-semibold text-zinc-900">{stats.employmentStatusSummary.total}</div>
<div className="mt-2 text-xs text-zinc-500">按 active 账号员工汇总</div>
</section>
<section className="card-std p-5">
<div className="text-sm text-zinc-500">近 30 天入职</div>
<div className="mt-2 text-3xl font-semibold text-zinc-900">{stats.recentMovements.hires}</div>
<div className="mt-2 text-xs text-zinc-500">
{stats.recentMovements.dateFrom} 至 {stats.recentMovements.dateTo}
</div>
</section>
<section className="card-std p-5">
<div className="text-sm text-zinc-500">近 30 天离职</div>
<div className="mt-2 text-3xl font-semibold text-zinc-900">{stats.recentMovements.departures}</div>
<div className="mt-2 text-xs text-zinc-500">
{stats.recentMovements.dateFrom} 至 {stats.recentMovements.dateTo}
</div>
</section>
<section className="card-std p-5">
<div className="text-sm text-zinc-500">30 天内合同到期</div>
<div className="mt-2 text-3xl font-semibold text-zinc-900">{stats.expiringContracts.count}</div>
<div className="mt-2 text-xs text-zinc-500">
{stats.expiringContracts.dateFrom} 至 {stats.expiringContracts.dateTo}
</div>
</section>
</div>
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<section className="card-std p-6">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-zinc-900">雇用状态分布</h2>
<p className="mt-1 text-sm text-zinc-500">按当前 active 账号员工的 employment status 聚合。</p>
</div>
</div>
<div className="mt-6 space-y-3">
{stats.employmentStatusSummary.items.length ? (
stats.employmentStatusSummary.items.map((item) => (
<div key={item.status} className="flex items-center justify-between rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3">
<div className="text-sm font-medium text-zinc-800">{employmentStatusLabel(item.status)}</div>
<div className="text-lg font-semibold text-zinc-900">{item.count}</div>
</div>
))
) : (
<div className="text-sm text-zinc-500">暂无雇用状态统计。</div>
)}
</div>
</section>
<section className="card-std p-6">
<div>
<h2 className="text-lg font-semibold text-zinc-900">部门人数分布</h2>
<p className="mt-1 text-sm text-zinc-500">按当前 active 账号员工的部门归属统计。</p>
</div>
<div className="mt-6 space-y-3">
{stats.departmentHeadcount.length ? (
stats.departmentHeadcount.map((item) => (
<div key={item.departmentId} className="flex items-center justify-between rounded-2xl border border-zinc-200 bg-white px-4 py-3">
<div className="text-sm font-medium text-zinc-800">{item.departmentName}</div>
<div className="text-lg font-semibold text-zinc-900">{item.count}</div>
</div>
))
) : (
<div className="text-sm text-zinc-500">暂无部门人数统计。</div>
)}
</div>
</section>
</div>
</div>
);
}
export default PeopleStatsPage;
+10
查看文件
@@ -34,6 +34,7 @@ import {
OrganizationPage,
PaymentBatchesPage,
PeoplePage,
PeopleStatsPage,
PortalAnnouncementsPage,
PortalHomePage,
ProjectsPage,
@@ -321,6 +322,15 @@ export function AppRouter() {
}
/>
<Route
path="people/stats"
element={
<RequirePermission anyOf={["system.users", "report.dashboard.view"]}>
{withRouteFallback(<PeopleStatsPage />)}
</RequirePermission>
}
/>
<Route path="people/:employeeId" element={withRouteFallback(<EmployeeProfilePage />)} />
<Route
@@ -0,0 +1 @@
export { PeopleStatsPage as default } from "../../pages/PeopleStatsPage";
+1
查看文件
@@ -21,6 +21,7 @@ export const NotificationsPage = lazy(() => import("./NotificationsPage"));
export const AnnouncementsPage = lazy(() => import("./AnnouncementsPage"));
export const PaymentBatchesPage = lazy(() => import("./PaymentBatchesPage"));
export const PeoplePage = lazy(() => import("./PeoplePage"));
export const PeopleStatsPage = lazy(() => import("./PeopleStatsPage"));
export const OrganizationPage = lazy(() => import("./OrganizationPage"));
export const EmployeeProfilePage = lazy(() => import("./EmployeeProfilePage"));
export const TeamPresencePage = lazy(() => import("./TeamPresencePage"));