R44 A2-A8 完成员工高级筛选批量操作与统计页
这个提交包含在:
+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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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"));
|
||||
|
||||
在新工单中引用
屏蔽一个用户