R44 A2-A8 完成员工高级筛选批量操作与统计页
这个提交包含在:
+101
-3
@@ -1,6 +1,7 @@
|
|||||||
import { apiRequestData } from "./http";
|
import { apiRequestData } from "./http";
|
||||||
|
|
||||||
export type EmployeeStatus = "active" | "archived";
|
export type EmployeeStatus = "active" | "archived";
|
||||||
|
export type EmployeeEmploymentStatus = "onboarding" | "probation" | "active" | "offboarding" | "inactive";
|
||||||
export type EmployeeContractType = "labor" | "nda" | "non_compete" | "internship";
|
export type EmployeeContractType = "labor" | "nda" | "non_compete" | "internship";
|
||||||
export type EmployeeContractStatus = "active" | "expired" | "terminated";
|
export type EmployeeContractStatus = "active" | "expired" | "terminated";
|
||||||
|
|
||||||
@@ -17,8 +18,12 @@ export type EmployeeSummary = {
|
|||||||
email: string;
|
email: string;
|
||||||
status: EmployeeStatus;
|
status: EmployeeStatus;
|
||||||
departmentId: string;
|
departmentId: string;
|
||||||
|
department: EmployeeDepartmentSummary | null;
|
||||||
directManagerId: string | null;
|
directManagerId: string | null;
|
||||||
|
directManager: { id: string; employeeNo: string; name: string } | null;
|
||||||
title: string;
|
title: string;
|
||||||
|
employmentStatus: EmployeeEmploymentStatus | null;
|
||||||
|
hireDate: string | null;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
roleCodes: string[];
|
roleCodes: string[];
|
||||||
createdAt: string | null;
|
createdAt: string | null;
|
||||||
@@ -119,7 +124,7 @@ export type EmployeeProfile = {
|
|||||||
phone: string;
|
phone: string;
|
||||||
email: string;
|
email: string;
|
||||||
status: EmployeeStatus;
|
status: EmployeeStatus;
|
||||||
employmentStatus: "onboarding" | "probation" | "active" | "offboarding" | "inactive";
|
employmentStatus: EmployeeEmploymentStatus;
|
||||||
departmentId: string;
|
departmentId: string;
|
||||||
title: string;
|
title: string;
|
||||||
directManagerId: string | null;
|
directManagerId: string | null;
|
||||||
@@ -158,19 +163,80 @@ export type EmployeeProfile = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchEmployees(params: {
|
export type FetchEmployeesParams = {
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
status?: EmployeeStatus;
|
status?: EmployeeStatus;
|
||||||
departmentId?: string;
|
departmentId?: string;
|
||||||
q?: 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();
|
const search = new URLSearchParams();
|
||||||
search.set("page", String(params.page));
|
search.set("page", String(params.page));
|
||||||
search.set("pageSize", String(params.pageSize));
|
search.set("pageSize", String(params.pageSize));
|
||||||
if (params.status) search.set("status", params.status);
|
if (params.status) search.set("status", params.status);
|
||||||
if (params.departmentId) search.set("departmentId", params.departmentId);
|
if (params.departmentId) search.set("departmentId", params.departmentId);
|
||||||
if (params.q) search.set("q", params.q);
|
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()}`);
|
return apiRequestData<EmployeeListResponse>(`/api/employees?${search.toString()}`);
|
||||||
}
|
}
|
||||||
@@ -264,6 +330,10 @@ export async function fetchEmployeeOrganization() {
|
|||||||
return apiRequestData<{ items: OrganizationDepartmentSection[] }>("/api/employees/organization");
|
return apiRequestData<{ items: OrganizationDepartmentSection[] }>("/api/employees/organization");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchEmployeeStats() {
|
||||||
|
return apiRequestData<EmployeeStatsResponse>("/api/employees/stats");
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchEmployeeContracts(employeeId: string) {
|
export async function fetchEmployeeContracts(employeeId: string) {
|
||||||
return apiRequestData<{ items: EmployeeContract[] }>(`/api/employees/${employeeId}/contracts`);
|
return apiRequestData<{ items: EmployeeContract[] }>(`/api/employees/${employeeId}/contracts`);
|
||||||
}
|
}
|
||||||
@@ -316,3 +386,31 @@ export async function skipEmployeeChecklistTask(employeeId: string, taskId: stri
|
|||||||
body: JSON.stringify({}),
|
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";
|
status?: "active" | "archived";
|
||||||
departmentId?: string;
|
departmentId?: string;
|
||||||
q?: string;
|
q?: string;
|
||||||
|
title?: string;
|
||||||
|
managerId?: string;
|
||||||
|
hireDateFrom?: string;
|
||||||
|
hireDateTo?: string;
|
||||||
|
employmentStatus?: "onboarding" | "probation" | "active" | "offboarding" | "inactive";
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}) {
|
}) {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
@@ -68,6 +73,11 @@ export async function exportEmployees(params: {
|
|||||||
if (params.status) qs.set("status", params.status);
|
if (params.status) qs.set("status", params.status);
|
||||||
if (params.departmentId) qs.set("departmentId", params.departmentId);
|
if (params.departmentId) qs.set("departmentId", params.departmentId);
|
||||||
if (params.q) qs.set("q", params.q);
|
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));
|
if (typeof params.limit === "number") qs.set("limit", String(params.limit));
|
||||||
|
|
||||||
return apiRequestBlob(`/api/exports/employees?${qs.toString()}`);
|
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 { Link } from "react-router-dom";
|
||||||
import { useState } from "react";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
batchDisableUsers,
|
||||||
|
batchEnableUsers,
|
||||||
|
batchSetEmployeesManager,
|
||||||
|
batchTransferEmployeesDepartment,
|
||||||
fetchEmployees,
|
fetchEmployees,
|
||||||
|
fetchEmployeeOrganization,
|
||||||
regularizeEmployee,
|
regularizeEmployee,
|
||||||
startEmployeeOffboarding,
|
startEmployeeOffboarding,
|
||||||
startEmployeeOnboarding,
|
startEmployeeOnboarding,
|
||||||
|
type EmployeeEmploymentStatus,
|
||||||
|
type OrganizationTreeNode,
|
||||||
} from "../api/employees";
|
} from "../api/employees";
|
||||||
import { exportEmployees } from "../api/exports";
|
import { exportEmployees } from "../api/exports";
|
||||||
import { fetchDepartments } from "../api/departments";
|
import { fetchDepartments } from "../api/departments";
|
||||||
@@ -13,32 +20,94 @@ import { isApiError } from "../api/types";
|
|||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasPermission } from "../auth/permissions";
|
import { hasPermission } from "../auth/permissions";
|
||||||
import { downloadBlob } from "../lib/download";
|
|
||||||
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
|
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
|
||||||
|
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
import { Button } from "../components/ui/button";
|
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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
|
||||||
import { StatusBadge } from "../components/common/StatusBadge";
|
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";
|
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() {
|
export function PeoplePage() {
|
||||||
const { permissions } = useAuth();
|
const { permissions } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const canManageUsers = hasPermission(permissions, "system.users");
|
||||||
|
const canViewStats = canManageUsers || hasPermission(permissions, "report.dashboard.view");
|
||||||
|
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [status, setStatus] = useState<"" | "active" | "archived">("");
|
const [status, setStatus] = useState<"" | "active" | "archived">("");
|
||||||
const [departmentId, setDepartmentId] = useState("");
|
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 [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({
|
const departmentsQuery = useQuery({
|
||||||
queryKey: ["departments", "people-page", "active"],
|
queryKey: ["departments", "people-page", "active"],
|
||||||
queryFn: () => fetchDepartments({ includeArchived: false }),
|
queryFn: () => fetchDepartments({ includeArchived: false }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const organizationQuery = useQuery({
|
||||||
|
queryKey: ["employee-organization", "people-page"],
|
||||||
|
queryFn: fetchEmployeeOrganization,
|
||||||
|
});
|
||||||
|
|
||||||
const employeesQuery = useQuery({
|
const employeesQuery = useQuery({
|
||||||
queryKey: ["employees", "people-page", q, status, departmentId],
|
queryKey: ["employees", "people-page", q, status, departmentId, title, managerId, hireDateFrom, hireDateTo, employmentStatus],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
fetchEmployees({
|
fetchEmployees({
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -46,54 +115,56 @@ export function PeoplePage() {
|
|||||||
q: q || undefined,
|
q: q || undefined,
|
||||||
status: status || undefined,
|
status: status || undefined,
|
||||||
departmentId: departmentId || 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({
|
const onboardingMutation = useMutation({
|
||||||
mutationFn: (employeeId: string) => startEmployeeOnboarding(employeeId),
|
mutationFn: (employeeId: string) => startEmployeeOnboarding(employeeId),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["employees"] });
|
await invalidatePeopleQueries();
|
||||||
toast.success("已发起入职流程");
|
toast.success("已发起入职流程");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const message = isApiError(error)
|
toast.error(mapApiMessage(error, "发起入职失败"));
|
||||||
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
|
|
||||||
: error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "发起入职失败";
|
|
||||||
toast.error(message);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const regularizeMutation = useMutation({
|
const regularizeMutation = useMutation({
|
||||||
mutationFn: (employeeId: string) => regularizeEmployee(employeeId),
|
mutationFn: (employeeId: string) => regularizeEmployee(employeeId),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["employees"] });
|
await invalidatePeopleQueries();
|
||||||
toast.success("已完成转正");
|
toast.success("已完成转正");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const message = isApiError(error)
|
toast.error(mapApiMessage(error, "转正失败"));
|
||||||
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
|
|
||||||
: error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "转正失败";
|
|
||||||
toast.error(message);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const offboardingMutation = useMutation({
|
const offboardingMutation = useMutation({
|
||||||
mutationFn: (employeeId: string) => startEmployeeOffboarding(employeeId),
|
mutationFn: (employeeId: string) => startEmployeeOffboarding(employeeId),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["employees"] });
|
await invalidatePeopleQueries();
|
||||||
toast.success("已发起离职流程");
|
toast.success("已发起离职流程");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const message = isApiError(error)
|
toast.error(mapApiMessage(error, "发起离职失败"));
|
||||||
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
|
|
||||||
: error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "发起离职失败";
|
|
||||||
toast.error(message);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,6 +175,11 @@ export function PeoplePage() {
|
|||||||
q: q || undefined,
|
q: q || undefined,
|
||||||
status: status || undefined,
|
status: status || undefined,
|
||||||
departmentId: departmentId || undefined,
|
departmentId: departmentId || undefined,
|
||||||
|
title: title || undefined,
|
||||||
|
managerId: managerId || undefined,
|
||||||
|
hireDateFrom: hireDateFrom || undefined,
|
||||||
|
hireDateTo: hireDateTo || undefined,
|
||||||
|
employmentStatus: employmentStatus || undefined,
|
||||||
}),
|
}),
|
||||||
onSuccess: (response, format) => {
|
onSuccess: (response, format) => {
|
||||||
downloadBlob(response.blob, response.filename ?? `employees.${format}`);
|
downloadBlob(response.blob, response.filename ?? `employees.${format}`);
|
||||||
@@ -111,27 +187,117 @@ export function PeoplePage() {
|
|||||||
toast.success("导出完成");
|
toast.success("导出完成");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const message = isApiError(error)
|
const message = mapApiMessage(error, "导出失败");
|
||||||
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
|
|
||||||
: error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "导出失败";
|
|
||||||
setExportError(message);
|
setExportError(message);
|
||||||
toast.error(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 items = employeesQuery.data?.items ?? [];
|
||||||
const departments = departmentsQuery.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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="人员管理"
|
title="人员管理"
|
||||||
subtitle="小团队用这里管入职、转正、离职与档案入口。"
|
subtitle="按当前员工档案与账号状态做筛选、批量处理和统计查看。"
|
||||||
actions={
|
actions={
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{canExportEmployees ? (
|
{canViewStats ? (
|
||||||
|
<Button asChild variant="secondary" type="button">
|
||||||
|
<Link to="/people/stats">员工统计</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canManageUsers ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="secondary" type="button" disabled={exportMutation.isPending}>
|
<Button variant="secondary" type="button" disabled={exportMutation.isPending}>
|
||||||
@@ -148,22 +314,24 @@ export function PeoplePage() {
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
) : null}
|
) : null}
|
||||||
<Button asChild variant="secondary" type="button">
|
{canManageUsers ? (
|
||||||
<Link to="/system/users">先新建账号</Link>
|
<Button asChild variant="secondary" type="button">
|
||||||
</Button>
|
<Link to="/system/users">先新建账号</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="card-std p-4">
|
<div className="card-std space-y-4 p-4">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
|
||||||
<Input
|
<Input
|
||||||
placeholder="搜索姓名、工号、手机号、邮箱"
|
placeholder="搜索姓名、工号、手机号、邮箱"
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(event) => setQ(event.target.value)}
|
onChange={(event) => setQ(event.target.value)}
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
className="select-std w-auto"
|
className="select-std w-full"
|
||||||
value={departmentId}
|
value={departmentId}
|
||||||
onChange={(event) => setDepartmentId(event.target.value)}
|
onChange={(event) => setDepartmentId(event.target.value)}
|
||||||
>
|
>
|
||||||
@@ -175,17 +343,103 @@ export function PeoplePage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
className="select-std w-auto"
|
className="select-std w-full"
|
||||||
value={status}
|
value={status}
|
||||||
onChange={(event) => setStatus(event.target.value as "" | "active" | "archived")}
|
onChange={(event) => setStatus(event.target.value as "" | "active" | "archived")}
|
||||||
>
|
>
|
||||||
<option value="">全部状态</option>
|
<option value="">全部账号状态</option>
|
||||||
<option value="active">生效中</option>
|
<option value="active">生效中</option>
|
||||||
<option value="archived">已归档</option>
|
<option value="archived">已归档</option>
|
||||||
</select>
|
</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>
|
||||||
</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">
|
<div className="card-std p-6">
|
||||||
{exportError ? (
|
{exportError ? (
|
||||||
<p className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
<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}
|
) : null}
|
||||||
{employeesQuery.isError ? (
|
{employeesQuery.isError ? (
|
||||||
<p className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
<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)
|
{mapApiMessage(employeesQuery.error, "加载人员列表失败")}
|
||||||
? `${employeesQuery.error.code}: ${mapApiErrorMessage(
|
|
||||||
employeesQuery.error.code,
|
|
||||||
employeesQuery.error.details,
|
|
||||||
employeesQuery.error.message,
|
|
||||||
)}`
|
|
||||||
: "加载人员列表失败"}
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
{canManageUsers ? <TableHead>选择</TableHead> : null}
|
||||||
<TableHead>员工</TableHead>
|
<TableHead>员工</TableHead>
|
||||||
|
<TableHead>部门 / 上级</TableHead>
|
||||||
|
<TableHead>岗位 / 雇用状态</TableHead>
|
||||||
<TableHead>角色</TableHead>
|
<TableHead>角色</TableHead>
|
||||||
<TableHead>账号状态</TableHead>
|
<TableHead>账号状态</TableHead>
|
||||||
<TableHead>操作</TableHead>
|
<TableHead>操作</TableHead>
|
||||||
@@ -216,6 +467,18 @@ export function PeoplePage() {
|
|||||||
{items.length ? (
|
{items.length ? (
|
||||||
items.map((item) => (
|
items.map((item) => (
|
||||||
<TableRow key={item.id}>
|
<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>
|
<TableCell>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<EmployeeAvatar name={item.name} src={item.avatarUrl} />
|
<EmployeeAvatar name={item.name} src={item.avatarUrl} />
|
||||||
@@ -227,29 +490,51 @@ export function PeoplePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</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>{item.roleCodes.join(" / ") || "-"}</TableCell>
|
||||||
<TableCell><StatusBadge status={item.status} /></TableCell>
|
<TableCell>
|
||||||
|
<StatusBadge status={item.status} />
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button asChild size="sm" variant="secondary" type="button">
|
<Button asChild size="sm" variant="secondary" type="button">
|
||||||
<Link to={`/people/${item.id}`}>查看档案</Link>
|
<Link to={`/people/${item.id}`}>查看档案</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" type="button" disabled={onboardingMutation.isPending} onClick={() => onboardingMutation.mutate(item.id)}>
|
{canManageUsers ? (
|
||||||
发起入职
|
<>
|
||||||
</Button>
|
<Button size="sm" type="button" disabled={onboardingMutation.isPending} onClick={() => onboardingMutation.mutate(item.id)}>
|
||||||
<Button size="sm" variant="secondary" type="button" disabled={regularizeMutation.isPending} onClick={() => regularizeMutation.mutate(item.id)}>
|
发起入职
|
||||||
转正
|
</Button>
|
||||||
</Button>
|
<Button size="sm" variant="secondary" type="button" disabled={regularizeMutation.isPending} onClick={() => regularizeMutation.mutate(item.id)}>
|
||||||
<Button size="sm" variant="secondary" type="button" disabled={offboardingMutation.isPending} onClick={() => offboardingMutation.mutate(item.id)}>
|
转正
|
||||||
发起离职
|
</Button>
|
||||||
</Button>
|
<Button size="sm" variant="secondary" type="button" disabled={offboardingMutation.isPending} onClick={() => offboardingMutation.mutate(item.id)}>
|
||||||
|
发起离职
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="py-3 text-zinc-500" colSpan={4}>
|
<TableCell className="py-3 text-zinc-500" colSpan={tableColumnCount}>
|
||||||
暂无匹配员工
|
暂无匹配员工
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -257,6 +542,111 @@ export function PeoplePage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</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>
|
</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,
|
OrganizationPage,
|
||||||
PaymentBatchesPage,
|
PaymentBatchesPage,
|
||||||
PeoplePage,
|
PeoplePage,
|
||||||
|
PeopleStatsPage,
|
||||||
PortalAnnouncementsPage,
|
PortalAnnouncementsPage,
|
||||||
PortalHomePage,
|
PortalHomePage,
|
||||||
ProjectsPage,
|
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 path="people/:employeeId" element={withRouteFallback(<EmployeeProfilePage />)} />
|
||||||
|
|
||||||
<Route
|
<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 AnnouncementsPage = lazy(() => import("./AnnouncementsPage"));
|
||||||
export const PaymentBatchesPage = lazy(() => import("./PaymentBatchesPage"));
|
export const PaymentBatchesPage = lazy(() => import("./PaymentBatchesPage"));
|
||||||
export const PeoplePage = lazy(() => import("./PeoplePage"));
|
export const PeoplePage = lazy(() => import("./PeoplePage"));
|
||||||
|
export const PeopleStatsPage = lazy(() => import("./PeopleStatsPage"));
|
||||||
export const OrganizationPage = lazy(() => import("./OrganizationPage"));
|
export const OrganizationPage = lazy(() => import("./OrganizationPage"));
|
||||||
export const EmployeeProfilePage = lazy(() => import("./EmployeeProfilePage"));
|
export const EmployeeProfilePage = lazy(() => import("./EmployeeProfilePage"));
|
||||||
export const TeamPresencePage = lazy(() => import("./TeamPresencePage"));
|
export const TeamPresencePage = lazy(() => import("./TeamPresencePage"));
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||||
|
A1,R44 合同建档与范围冻结,"创建 R44 Issue CSV,冻结范围为高级筛选、批量操作、统计页;锁定批量调部门/设置上级沿用 transfer 生命周期语义,统计页采用独立 /people/stats。","issues/2026-03-29_r44-employee-batch-filter-stats.csv 包含完整列、依赖关系和测试方法;不夹带导入、档案编辑、合同附件上传。","对照 AGENTS.md 与 issues/README.md 检查 CSV 列完整、Dependencies 为 JSON 数组字符串、Test_Method 含业务流程步骤;确认范围仅覆盖本轮 3 项。",manual,DONE,DONE,DONE,issues/2026-03-29_r44-employee-batch-filter-stats.csv,"[]",none
|
||||||
|
A2,员工高级筛选后端与导出对齐,"扩展 /api/employees 和 /api/exports/employees 的 query,新增 title、managerId、hireDateFrom、hireDateTo、employmentStatus;抽取共享筛选 builder;将 /api/employees 读取权限对齐到 system.users + report.dashboard.view。","列表与导出支持全部高级筛选;title 模糊、managerId 精确、hireDate 闭区间、employmentStatus 枚举生效;report.dashboard.view 可只读访问列表;导出仍仅 system.users。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/users.js && node --check src/routes/exports.js && node --check src/routes/employees.js。业务:分别用 system.users 和 report.dashboard.view 访问 /people,按 q+departmentId+status+title+managerId+hireDateFrom/hireDateTo+employmentStatus 组合筛选,列表结果正确;同样条件导出 CSV/XLSX 与页面一致;hireDateFrom>hireDateTo 返回 VALID_001。",manual,DONE,DONE,DONE,server/src/routes/users.js|server/src/routes/exports.js|server/src/routes/employees.js|server/src/services/employeeDirectoryFilters.js,"[""A1""]","回滚:删除高级筛选 query 与共享 builder,恢复 /api/employees 原读取权限口径。"
|
||||||
|
A3,PeoplePage 高级筛选前端接入,"在 PeoplePage 增加职位、直属上级、入职日期范围、雇用状态筛选,并把全部筛选参数透传到 fetchEmployees/exportEmployees;页头增加员工统计入口。","PeoplePage 可直接组合高级筛选且列表自动刷新;导出继续复用当前筛选状态;report.dashboard.view 可筛选只读列表但看不到导出按钮。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:在 PeoplePage 依次修改职位、直属上级、入职日期范围、雇用状态,列表立即刷新;清空筛选后恢复;system.users 可导出当前筛选结果;report.dashboard.view 无导出入口。",manual,DONE,DONE,DONE,client/src/pages/PeoplePage.tsx|client/src/api/employees.ts|client/src/api/exports.ts,"[""A2""]","直属上级候选项复用 /api/employees/organization 拍平,不新增重型选择组件。"
|
||||||
|
A4,员工批量操作后端,"新增 /api/users/batch/transfer-department、/enable、/disable、/set-manager;事务内整批执行;复用单条 transfer 和启停账号语义;每批写审计日志。","4 个批量端点均仅 system.users 可用;单次上限 50 人;任一员工失败整批回滚;批量调部门/设置上级会为每个员工写 transfer 生命周期事件和 checklist;批量启停复用 token_version 与 authSession 吊销语义。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/users.js && node --check src/routes/employees.js。业务:选 2-3 名员工批量调部门后,员工部门变化且每人新增 transfer 事件与 handover_note;批量设置上级后 direct_manager_id 更新且新增 transfer 事件;批量禁用后账号归档且会话吊销;批量启用后恢复 active;包含自己执行批量禁用返回 409 且整批不落库;提交 51 人返回校验错误。",manual,DONE,DONE,DONE,server/src/routes/users.js|server/src/routes/employees.js|server/src/services/employeeLifecycle.js,"[""A1""]","回滚:回撤批量路由和共享 helper;已产生的 lifecycle/audit 记录作为业务痕迹保留,不做数据回写。"
|
||||||
|
A5,PeoplePage 多选与批量操作栏,"在 PeoplePage 增加 checkbox 多选、批量操作栏和对应弹窗;仅 system.users 可见;与现有筛选/列表共存。","system.users 可在当前页多选员工并执行批量调部门、批量启用/禁用、批量设置上级;达到 50 人停止继续勾选;筛选变化、数据刷新、操作成功后清空选择;report.dashboard.view 看不到任何批量入口。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:在 PeoplePage 勾选多名员工后出现批量操作栏;批量调部门、启用、禁用、设置上级分别成功并刷新列表;筛选变化后已选中项被清空;report.dashboard.view 进入页面仍为只读列表。",manual,DONE,DONE,DONE,client/src/pages/PeoplePage.tsx|client/src/api/employees.ts,"[""A3"",""A4""]","本轮不做跨页全选;批量设置上级要求选择一个目标上级,不支持清空直属上级。"
|
||||||
|
A6,员工统计后端聚合接口,"新增 GET /api/employees/stats,返回雇用状态分布、部门人数分布、近 30 天入职/离职人数、30 天内即将到期合同数;实现仅用 Prisma groupBy/count。","/api/employees/stats 可被 system.users 和 report.dashboard.view 访问;返回仅包含聚合数据,无员工明细;统计口径按合同说明固定。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/employees.js。业务:构造含 onboarding/probation/active/offboarding/inactive、跨部门、近 30 天入职/离职、30 天内到期合同的样本数据后,请求 /api/employees/stats,返回的状态分布、部门人数、入离职人数、合同即将到期数均正确;report.dashboard.view 同样可访问。",manual,DONE,DONE,DONE,server/src/routes/employees.js|server/src/services/employeeStats.js,"[""A1""]","本轮不返回员工级提醒列表;回滚:删除 stats 路由与聚合 service。"
|
||||||
|
A7,员工统计页面与路由,"新增 /people/stats 页面和前端 API,展示统计卡片与简单列表;从 PeoplePage 页头进入,不新增全局导航项。","/people/stats 可由 system.users 与 report.dashboard.view 访问;页面展示状态分布、部门人数分布、近 30 天入离职、合同即将到期卡片;PeoplePage 入口与返回链路可用。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:从 PeoplePage 点击员工统计进入 /people/stats,页面加载成功;system.users 与 report.dashboard.view 都可查看;返回 PeoplePage 后现有筛选和列表流程不回归。",manual,DONE,DONE,DONE,client/src/pages/PeopleStatsPage.tsx|client/src/router/index.tsx|client/src/router/lazy/index.ts|client/src/router/lazy/PeopleStatsPage.ts|client/src/pages/PeoplePage.tsx|client/src/api/employees.ts,"[""A5"",""A6""]","不引入图表库,使用数字卡片 + 简单列表。"
|
||||||
|
A8,Reviewer 与 QA 收口,"按 AGENTS 编排触发 reviewer 与 qa,重点检查批量操作权限边界、事务回滚、安全审计和统计/筛选回归,并回填 CSV 状态。","A2-A7 的 Review1_Status 与 Regression_Status 全部有证据地回填;reviewer 风险项关闭;R40-R43 相关员工管理能力不回归。","Reviewer:审查 /api/employees 读权限调整、批量操作事务安全、transfer 生命周期复用、审计日志是否完整。QA:运行 backend post-check、frontend post-check,并手工回归 3 条主线:高级筛选+导出一致、批量调部门/启停/设置上级、/people/stats 汇总展示;同时确认 R40 档案编辑、R41 批量导入、R43 合同/头像/组织架构不回归。",manual,DONE,DONE,DONE,issues/2026-03-29_r44-employee-batch-filter-stats.csv,"[""A2"",""A3"",""A4"",""A5"",""A6"",""A7""]","reviewer 必启用:本轮涉及多人状态变更、权限边界与事务回滚。"
|
||||||
|
+29
-52
@@ -2,6 +2,8 @@ const sharp = require("sharp");
|
|||||||
const { z } = require("zod");
|
const { z } = require("zod");
|
||||||
|
|
||||||
const usersRoutes = require("./users");
|
const usersRoutes = require("./users");
|
||||||
|
const { buildDefaultChecklist, resolveEffectiveDate, applyEmployeeTransfer } = require("../services/employeeLifecycle");
|
||||||
|
const { buildEmployeeStats } = require("../services/employeeStats");
|
||||||
const { currentDateString } = require("../utils/date");
|
const { currentDateString } = require("../utils/date");
|
||||||
const { AppError } = require("../utils/AppError");
|
const { AppError } = require("../utils/AppError");
|
||||||
const { sendData } = require("../utils/reply");
|
const { sendData } = require("../utils/reply");
|
||||||
@@ -103,10 +105,6 @@ async function ensureFeatureEnabled(fastify, request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveEffectiveDate(rawValue) {
|
|
||||||
return rawValue ? new Date(`${rawValue}T00:00:00.000Z`) : new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
function canManageProfile(fastify, request, employeeId) {
|
function canManageProfile(fastify, request, employeeId) {
|
||||||
return request.employee.id === employeeId || fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "report.dashboard.view");
|
return request.employee.id === employeeId || fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "report.dashboard.view");
|
||||||
}
|
}
|
||||||
@@ -242,26 +240,6 @@ async function ensureEmployeeContractExists(prisma, employeeId, contractId) {
|
|||||||
return contract;
|
return contract;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDefaultChecklist(scene, employeeId, actorId) {
|
|
||||||
const common = { employee_id: employeeId };
|
|
||||||
if (scene === "onboarding") {
|
|
||||||
return [
|
|
||||||
{ ...common, scene, task_type: "account_setup", title: "完成账号与权限初始化", status: "pending" },
|
|
||||||
{ ...common, scene, task_type: "asset_issue", title: "确认办公资产发放", status: "pending" },
|
|
||||||
{ ...common, scene, task_type: "policy_ack", title: "确认制度与入职须知", status: "pending" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (scene === "transfer") {
|
|
||||||
return [{ ...common, scene, task_type: "handover_note", title: "确认调岗交接说明", status: "pending" }];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
{ ...common, scene, task_type: "asset_return", title: "确认资产归还/转交", status: "pending" },
|
|
||||||
{ ...common, scene, task_type: "access_revoke", title: "确认账号和权限回收", status: "pending" },
|
|
||||||
{ ...common, scene, task_type: "expense_clearance", title: "确认费用与审批待办已清理", status: "pending" },
|
|
||||||
{ ...common, scene, task_type: "handover_note", title: "确认离职交接说明", status: "pending" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function maybeAdvanceLifecycle(tx, employeeId) {
|
async function maybeAdvanceLifecycle(tx, employeeId) {
|
||||||
const employee = await tx.employee.findUnique({ where: { id: employeeId } });
|
const employee = await tx.employee.findUnique({ where: { id: employeeId } });
|
||||||
if (!employee) {
|
if (!employee) {
|
||||||
@@ -449,7 +427,12 @@ async function buildEmployeeProfile(prisma, employeeId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function employeesRoutes(fastify) {
|
async function employeesRoutes(fastify) {
|
||||||
await usersRoutes(fastify, { basePath: "/employees", allowDepartmentFilter: true });
|
await usersRoutes(fastify, {
|
||||||
|
basePath: "/employees",
|
||||||
|
allowDepartmentFilter: true,
|
||||||
|
listPermissionCodes: ["system.users", "report.dashboard.view"],
|
||||||
|
enableBatchActions: false,
|
||||||
|
});
|
||||||
|
|
||||||
fastify.get("/avatars/:employeeId", async (request, reply) => {
|
fastify.get("/avatars/:employeeId", async (request, reply) => {
|
||||||
const employeeId = parseId(request.params?.employeeId);
|
const employeeId = parseId(request.params?.employeeId);
|
||||||
@@ -549,6 +532,17 @@ async function employeesRoutes(fastify) {
|
|||||||
return sendData(reply, request, { items });
|
return sendData(reply, request, { items });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fastify.get("/employees/stats", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
if (!canViewPeopleDirectory(fastify, request)) {
|
||||||
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.stats.view" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await buildEmployeeStats(fastify.prisma, {
|
||||||
|
timeZone: fastify.config.appTimezone,
|
||||||
|
});
|
||||||
|
return sendData(reply, request, stats);
|
||||||
|
});
|
||||||
|
|
||||||
fastify.get("/employees/:id/profile", { preHandler: fastify.authenticate }, async (request, reply) => {
|
fastify.get("/employees/:id/profile", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
await ensureFeatureEnabled(fastify, request);
|
await ensureFeatureEnabled(fastify, request);
|
||||||
const employeeId = parseId(request.params?.id);
|
const employeeId = parseId(request.params?.id);
|
||||||
@@ -726,7 +720,7 @@ async function employeesRoutes(fastify) {
|
|||||||
created_by: request.employee.id,
|
created_by: request.employee.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const checklist = buildDefaultChecklist("onboarding", employeeId, request.employee.id).map((item) => ({ ...item, due_date: body.probationEndDate ? new Date(`${body.probationEndDate}T00:00:00.000Z`) : null }));
|
const checklist = buildDefaultChecklist("onboarding", employeeId).map((item) => ({ ...item, due_date: body.probationEndDate ? new Date(`${body.probationEndDate}T00:00:00.000Z`) : null }));
|
||||||
await tx.employeeChecklistTask.createMany({ data: checklist, skipDuplicates: false });
|
await tx.employeeChecklistTask.createMany({ data: checklist, skipDuplicates: false });
|
||||||
});
|
});
|
||||||
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
|
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
|
||||||
@@ -766,31 +760,14 @@ async function employeesRoutes(fastify) {
|
|||||||
const body = parseBody(TRANSFER_SCHEMA, request.body);
|
const body = parseBody(TRANSFER_SCHEMA, request.body);
|
||||||
await fastify.prisma.$transaction(async (tx) => {
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
await ensureEmployeeExists(tx, employeeId);
|
await ensureEmployeeExists(tx, employeeId);
|
||||||
await tx.employee.update({
|
await applyEmployeeTransfer(tx, {
|
||||||
where: { id: employeeId },
|
employeeId,
|
||||||
data: {
|
actorId: request.employee.id,
|
||||||
...(body.departmentId !== undefined ? { department_id: body.departmentId } : {}),
|
departmentId: body.departmentId,
|
||||||
...(body.directManagerId !== undefined ? { direct_manager_id: body.directManagerId } : {}),
|
directManagerId: body.directManagerId,
|
||||||
...(body.title !== undefined ? { title: body.title } : {}),
|
title: body.title,
|
||||||
updated_by: request.employee.id,
|
effectiveDate: body.effectiveDate,
|
||||||
},
|
notes: body.notes,
|
||||||
});
|
|
||||||
await tx.employeeLifecycleEvent.create({
|
|
||||||
data: {
|
|
||||||
employee_id: employeeId,
|
|
||||||
event_type: "transfer",
|
|
||||||
effective_date: resolveEffectiveDate(body.effectiveDate),
|
|
||||||
payload_json: {
|
|
||||||
departmentId: body.departmentId ?? null,
|
|
||||||
directManagerId: body.directManagerId ?? null,
|
|
||||||
title: body.title ?? null,
|
|
||||||
notes: body.notes ?? null,
|
|
||||||
},
|
|
||||||
created_by: request.employee.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await tx.employeeChecklistTask.createMany({
|
|
||||||
data: buildDefaultChecklist("transfer", employeeId, request.employee.id),
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
|
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
|
||||||
@@ -823,7 +800,7 @@ async function employeesRoutes(fastify) {
|
|||||||
created_by: request.employee.id,
|
created_by: request.employee.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await tx.employeeChecklistTask.createMany({ data: buildDefaultChecklist("offboarding", employeeId, request.employee.id) });
|
await tx.employeeChecklistTask.createMany({ data: buildDefaultChecklist("offboarding", employeeId) });
|
||||||
});
|
});
|
||||||
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
|
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
|
||||||
return sendData(reply, request, profile);
|
return sendData(reply, request, profile);
|
||||||
|
|||||||
+17
-20
@@ -3,6 +3,11 @@ const { Readable } = require("node:stream");
|
|||||||
const ExcelJS = require("exceljs");
|
const ExcelJS = require("exceljs");
|
||||||
const { z } = require("zod");
|
const { z } = require("zod");
|
||||||
|
|
||||||
|
const {
|
||||||
|
EMPLOYMENT_STATUS_VALUES,
|
||||||
|
buildEmployeeDirectoryWhere,
|
||||||
|
parseEmployeeDirectoryFilters,
|
||||||
|
} = require("../services/employeeDirectoryFilters");
|
||||||
const {
|
const {
|
||||||
HISTORY_STATUS_VALUES,
|
HISTORY_STATUS_VALUES,
|
||||||
buildPresenceValidationError,
|
buildPresenceValidationError,
|
||||||
@@ -61,6 +66,11 @@ const EMPLOYEE_EXPORT_QUERY_SCHEMA = z.object({
|
|||||||
status: z.enum(["active", "archived"]).optional(),
|
status: z.enum(["active", "archived"]).optional(),
|
||||||
departmentId: z.string().uuid().optional(),
|
departmentId: z.string().uuid().optional(),
|
||||||
q: z.string().max(100).optional(),
|
q: z.string().max(100).optional(),
|
||||||
|
title: z.string().max(100).optional(),
|
||||||
|
managerId: z.string().uuid().optional(),
|
||||||
|
hireDateFrom: DATE_ONLY_SCHEMA.optional(),
|
||||||
|
hireDateTo: DATE_ONLY_SCHEMA.optional(),
|
||||||
|
employmentStatus: z.enum(EMPLOYMENT_STATUS_VALUES).optional(),
|
||||||
limit: z.coerce.number().int().min(1).max(20000).optional(),
|
limit: z.coerce.number().int().min(1).max(20000).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -193,25 +203,6 @@ function buildDashboardWhere(request, query) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEmployeeExportWhere(query) {
|
|
||||||
const q = query.q?.trim() ?? "";
|
|
||||||
|
|
||||||
return {
|
|
||||||
...(query.status ? { status: query.status } : {}),
|
|
||||||
...(query.departmentId ? { department_id: query.departmentId } : {}),
|
|
||||||
...(q
|
|
||||||
? {
|
|
||||||
OR: [
|
|
||||||
{ employee_no: { contains: q, mode: "insensitive" } },
|
|
||||||
{ name: { contains: q, mode: "insensitive" } },
|
|
||||||
{ phone: { contains: q, mode: "insensitive" } },
|
|
||||||
{ email: { contains: q, mode: "insensitive" } },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function* streamDashboardReimbursementsCsv(prisma, where, limit) {
|
async function* streamDashboardReimbursementsCsv(prisma, where, limit) {
|
||||||
yield "\ufeff";
|
yield "\ufeff";
|
||||||
yield csvRow([
|
yield csvRow([
|
||||||
@@ -907,7 +898,8 @@ async function exportsRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const query = parseQuery(EMPLOYEE_EXPORT_QUERY_SCHEMA, request.query);
|
const query = parseQuery(EMPLOYEE_EXPORT_QUERY_SCHEMA, request.query);
|
||||||
const where = buildEmployeeExportWhere(query);
|
const filters = parseEmployeeDirectoryFilters(query, { allowDepartmentFilter: true });
|
||||||
|
const where = buildEmployeeDirectoryWhere(filters);
|
||||||
const format = resolveExportFormat(query);
|
const format = resolveExportFormat(query);
|
||||||
const limit = query.limit ?? 5000;
|
const limit = query.limit ?? 5000;
|
||||||
const total = await fastify.prisma.employee.count({ where });
|
const total = await fastify.prisma.employee.count({ where });
|
||||||
@@ -922,6 +914,11 @@ async function exportsRoutes(fastify) {
|
|||||||
status: query.status ?? null,
|
status: query.status ?? null,
|
||||||
departmentId: query.departmentId ?? null,
|
departmentId: query.departmentId ?? null,
|
||||||
q: query.q?.trim() || null,
|
q: query.q?.trim() || null,
|
||||||
|
title: query.title?.trim() || null,
|
||||||
|
managerId: query.managerId ?? null,
|
||||||
|
hireDateFrom: query.hireDateFrom ?? null,
|
||||||
|
hireDateTo: query.hireDateTo ?? null,
|
||||||
|
employmentStatus: query.employmentStatus ?? null,
|
||||||
limit,
|
limit,
|
||||||
matchedTotal: total,
|
matchedTotal: total,
|
||||||
},
|
},
|
||||||
|
|||||||
+407
-178
@@ -5,6 +5,13 @@ const { Readable } = require("node:stream");
|
|||||||
const ExcelJS = require("exceljs");
|
const ExcelJS = require("exceljs");
|
||||||
const { z } = require("zod");
|
const { z } = require("zod");
|
||||||
|
|
||||||
|
const {
|
||||||
|
EMPLOYMENT_STATUS_VALUES,
|
||||||
|
buildEmployeeDirectoryWhere,
|
||||||
|
parseEmployeeDirectoryFilters,
|
||||||
|
} = require("../services/employeeDirectoryFilters");
|
||||||
|
const { applyEmployeeTransfer } = require("../services/employeeLifecycle");
|
||||||
|
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||||
const { AppError } = require("../utils/AppError");
|
const { AppError } = require("../utils/AppError");
|
||||||
const { sendData } = require("../utils/reply");
|
const { sendData } = require("../utils/reply");
|
||||||
const { buildAuditLogData } = require("../utils/audit");
|
const { buildAuditLogData } = require("../utils/audit");
|
||||||
@@ -30,14 +37,9 @@ const roleCodeSchema = z.enum([
|
|||||||
"admin",
|
"admin",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const listQuerySchema = z.object({
|
const listPaginationSchema = z.object({
|
||||||
page: z.coerce.number().int().min(1).optional(),
|
page: z.coerce.number().int().min(1).optional(),
|
||||||
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
||||||
status: z.enum(["active", "archived"]).optional(),
|
|
||||||
q: z.string().max(100).optional(),
|
|
||||||
});
|
|
||||||
const listQueryWithDepartmentSchema = listQuerySchema.extend({
|
|
||||||
departmentId: z.string().uuid().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const createBodySchema = z.object({
|
const createBodySchema = z.object({
|
||||||
@@ -90,6 +92,18 @@ const USER_IMPORT_ALLOWED_MIME_TYPES = new Set([
|
|||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
]);
|
]);
|
||||||
const USER_IMPORT_MAX_ROWS = 500;
|
const USER_IMPORT_MAX_ROWS = 500;
|
||||||
|
const BATCH_EMPLOYEE_IDS_SCHEMA = z.array(z.string().uuid()).min(1);
|
||||||
|
const BATCH_TRANSFER_DEPARTMENT_SCHEMA = z.object({
|
||||||
|
employeeIds: BATCH_EMPLOYEE_IDS_SCHEMA,
|
||||||
|
departmentId: z.string().uuid(),
|
||||||
|
}).strict();
|
||||||
|
const BATCH_TOGGLE_SCHEMA = z.object({
|
||||||
|
employeeIds: BATCH_EMPLOYEE_IDS_SCHEMA,
|
||||||
|
}).strict();
|
||||||
|
const BATCH_SET_MANAGER_SCHEMA = z.object({
|
||||||
|
employeeIds: BATCH_EMPLOYEE_IDS_SCHEMA,
|
||||||
|
managerId: z.string().uuid(),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
function parseUserId(params) {
|
function parseUserId(params) {
|
||||||
const parsed = z.string().uuid().safeParse(params?.id);
|
const parsed = z.string().uuid().safeParse(params?.id);
|
||||||
@@ -105,9 +119,7 @@ function parseUserId(params) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseListQuery(query, options = {}) {
|
function parseListQuery(query, options = {}) {
|
||||||
const allowDepartmentFilter = options.allowDepartmentFilter === true;
|
const parsed = listPaginationSchema.safeParse(query ?? {});
|
||||||
const schema = allowDepartmentFilter ? listQueryWithDepartmentSchema : listQuerySchema;
|
|
||||||
const parsed = schema.safeParse(query ?? {});
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
code: "VALID_001",
|
code: "VALID_001",
|
||||||
@@ -120,9 +132,7 @@ function parseListQuery(query, options = {}) {
|
|||||||
return {
|
return {
|
||||||
page: parsed.data.page ?? 1,
|
page: parsed.data.page ?? 1,
|
||||||
pageSize: parsed.data.pageSize ?? 20,
|
pageSize: parsed.data.pageSize ?? 20,
|
||||||
status: parsed.data.status,
|
...parseEmployeeDirectoryFilters(query, options),
|
||||||
q: parsed.data.q?.trim() ?? "",
|
|
||||||
departmentId: allowDepartmentFilter ? parsed.data.departmentId : undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +195,18 @@ function mapEmployeeSummary(row) {
|
|||||||
email: row.email,
|
email: row.email,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
departmentId: row.department_id,
|
departmentId: row.department_id,
|
||||||
|
department: row.department ? { id: row.department.id, name: row.department.name } : null,
|
||||||
|
directManagerId: row.direct_manager_id ?? null,
|
||||||
|
directManager: row.direct_manager
|
||||||
|
? {
|
||||||
|
id: row.direct_manager.id,
|
||||||
|
employeeNo: row.direct_manager.employee_no,
|
||||||
|
name: row.direct_manager.name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
|
employmentStatus: row.employment_status ?? null,
|
||||||
|
hireDate: row.hire_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||||
avatarUrl: row.avatar_path ? `/api/avatars/${row.id}` : null,
|
avatarUrl: row.avatar_path ? `/api/avatars/${row.id}` : null,
|
||||||
roleCodes: row.roles.map((item) => item.role_code),
|
roleCodes: row.roles.map((item) => item.role_code),
|
||||||
createdAt: row.created_at?.toISOString?.() ?? null,
|
createdAt: row.created_at?.toISOString?.() ?? null,
|
||||||
@@ -258,6 +279,163 @@ function buildValidationError(details, statusCode = 400) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseBatchBody(schema, body) {
|
||||||
|
const parsed = schema.safeParse(body ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw buildValidationError(parsed.error.flatten());
|
||||||
|
}
|
||||||
|
|
||||||
|
const employeeIds = [...new Set(parsed.data.employeeIds)];
|
||||||
|
if (employeeIds.length > 50) {
|
||||||
|
throw buildValidationError({ employeeIds: "too_many" }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...parsed.data,
|
||||||
|
employeeIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureHasAnyPermission(fastify, request, permissionCodes) {
|
||||||
|
if (permissionCodes.some((code) => fastify.hasPermission(request.employee, code))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new AppError({
|
||||||
|
code: "PERM_001",
|
||||||
|
message: "无权限",
|
||||||
|
statusCode: 403,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureLifecycleFeatureEnabled(fastify, request) {
|
||||||
|
const gate = await evaluateFeatureFlag({
|
||||||
|
prisma: fastify.prisma,
|
||||||
|
config: fastify.config,
|
||||||
|
flagKey: FEATURE_FLAG_KEYS.employeeLifecycle,
|
||||||
|
departmentId: request.employee.department_id ?? null,
|
||||||
|
strictDepartment: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!gate.enabled) {
|
||||||
|
throw buildValidationError({ featureFlag: "employee_lifecycle_v1_disabled", reason: gate.reason }, 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDepartmentUsable(prisma, departmentId, fieldName = "departmentId") {
|
||||||
|
const department = await prisma.department.findUnique({
|
||||||
|
where: { id: departmentId },
|
||||||
|
select: { id: true, archived_at: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!department) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: { [fieldName]: "not_found" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (department.archived_at) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 409,
|
||||||
|
details: { [fieldName]: "archived" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureManagerUsable(prisma, managerId, options = {}) {
|
||||||
|
const fieldName = options.fieldName ?? "directManagerId";
|
||||||
|
const disallowedEmployeeIds = options.disallowedEmployeeIds ?? [];
|
||||||
|
|
||||||
|
if (disallowedEmployeeIds.includes(managerId)) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 409,
|
||||||
|
details: { [fieldName]: "self_not_allowed" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = await prisma.employee.findUnique({
|
||||||
|
where: { id: managerId },
|
||||||
|
select: { id: true, status: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!manager) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: { [fieldName]: "not_found" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manager.status !== "active") {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 409,
|
||||||
|
details: { [fieldName]: "inactive" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureEmployeesExist(prisma, employeeIds) {
|
||||||
|
const employees = await prisma.employee.findMany({
|
||||||
|
where: { id: { in: employeeIds } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (employees.length !== employeeIds.length) {
|
||||||
|
const existingIds = new Set(employees.map((item) => item.id));
|
||||||
|
const missingId = employeeIds.find((item) => !existingIds.has(item));
|
||||||
|
throw new AppError({
|
||||||
|
code: "SYS_404",
|
||||||
|
message: "Not Found",
|
||||||
|
statusCode: 404,
|
||||||
|
details: { employeeId: missingId ?? null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setEmployeeAccountStatus(tx, request, options) {
|
||||||
|
const updated = await tx.employee.update({
|
||||||
|
where: { id: options.employeeId },
|
||||||
|
data: {
|
||||||
|
status: options.status,
|
||||||
|
archived_at: options.status === "archived" ? options.now : null,
|
||||||
|
token_version: { increment: 1 },
|
||||||
|
updated_by: options.actorId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
token_version: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.authSession.updateMany({
|
||||||
|
where: { employee_id: options.employeeId, revoked_at: null },
|
||||||
|
data: { revoked_at: options.now },
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId: options.actorId,
|
||||||
|
action: options.status === "archived" ? "SYSTEM_USER_DISABLED" : "SYSTEM_USER_ENABLED",
|
||||||
|
targetType: "employee",
|
||||||
|
targetId: options.employeeId,
|
||||||
|
extra: {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
function generateOneTimePassword() {
|
function generateOneTimePassword() {
|
||||||
const password = `ResetA1${crypto.randomBytes(6).toString("hex")}`;
|
const password = `ResetA1${crypto.randomBytes(6).toString("hex")}`;
|
||||||
const errors = validatePassword(password);
|
const errors = validatePassword(password);
|
||||||
@@ -720,7 +898,10 @@ async function createSystemUser(tx, options) {
|
|||||||
email: true,
|
email: true,
|
||||||
status: true,
|
status: true,
|
||||||
department_id: true,
|
department_id: true,
|
||||||
|
direct_manager_id: true,
|
||||||
title: true,
|
title: true,
|
||||||
|
employment_status: true,
|
||||||
|
hire_date: true,
|
||||||
created_at: true,
|
created_at: true,
|
||||||
updated_at: true,
|
updated_at: true,
|
||||||
},
|
},
|
||||||
@@ -745,31 +926,22 @@ async function usersRoutes(fastify, opts = {}) {
|
|||||||
const basePath = basePathRaw.startsWith("/") ? basePathRaw : `/${basePathRaw}`;
|
const basePath = basePathRaw.startsWith("/") ? basePathRaw : `/${basePathRaw}`;
|
||||||
const routeBasePath = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
|
const routeBasePath = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
|
||||||
const allowDepartmentFilter = opts.allowDepartmentFilter === true;
|
const allowDepartmentFilter = opts.allowDepartmentFilter === true;
|
||||||
|
const listPermissionCodes =
|
||||||
|
Array.isArray(opts.listPermissionCodes) && opts.listPermissionCodes.length
|
||||||
|
? [...new Set(opts.listPermissionCodes)]
|
||||||
|
: ["system.users"];
|
||||||
|
const enableBatchActions = routeBasePath === "/users" && opts.enableBatchActions !== false;
|
||||||
|
|
||||||
fastify.get(
|
fastify.get(
|
||||||
routeBasePath,
|
routeBasePath,
|
||||||
{
|
{
|
||||||
preHandler: fastify.requirePermission("system.users"),
|
preHandler: fastify.authenticate,
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
|
ensureHasAnyPermission(fastify, request, listPermissionCodes);
|
||||||
const query = parseListQuery(request.query, { allowDepartmentFilter });
|
const query = parseListQuery(request.query, { allowDepartmentFilter });
|
||||||
const skip = (query.page - 1) * query.pageSize;
|
const skip = (query.page - 1) * query.pageSize;
|
||||||
|
const where = buildEmployeeDirectoryWhere(query);
|
||||||
const q = query.q;
|
|
||||||
const where = {
|
|
||||||
...(query.status ? { status: query.status } : {}),
|
|
||||||
...(query.departmentId ? { department_id: query.departmentId } : {}),
|
|
||||||
...(q
|
|
||||||
? {
|
|
||||||
OR: [
|
|
||||||
{ employee_no: { contains: q, mode: "insensitive" } },
|
|
||||||
{ name: { contains: q, mode: "insensitive" } },
|
|
||||||
{ phone: { contains: q, mode: "insensitive" } },
|
|
||||||
{ email: { contains: q, mode: "insensitive" } },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
fastify.prisma.employee.findMany({
|
fastify.prisma.employee.findMany({
|
||||||
@@ -785,10 +957,15 @@ async function usersRoutes(fastify, opts = {}) {
|
|||||||
email: true,
|
email: true,
|
||||||
status: true,
|
status: true,
|
||||||
department_id: true,
|
department_id: true,
|
||||||
|
direct_manager_id: true,
|
||||||
title: true,
|
title: true,
|
||||||
|
employment_status: true,
|
||||||
|
hire_date: true,
|
||||||
avatar_path: true,
|
avatar_path: true,
|
||||||
created_at: true,
|
created_at: true,
|
||||||
updated_at: true,
|
updated_at: true,
|
||||||
|
department: { select: { id: true, name: true } },
|
||||||
|
direct_manager: { select: { id: true, employee_no: true, name: true } },
|
||||||
roles: { select: { role_code: true } },
|
roles: { select: { role_code: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -874,49 +1051,10 @@ async function usersRoutes(fastify, opts = {}) {
|
|||||||
const hireDate = body.hireDate ? new Date(`${body.hireDate}T00:00:00.000Z`) : new Date();
|
const hireDate = body.hireDate ? new Date(`${body.hireDate}T00:00:00.000Z`) : new Date();
|
||||||
const departmentId = body.departmentId ?? request.employee.department_id;
|
const departmentId = body.departmentId ?? request.employee.department_id;
|
||||||
|
|
||||||
const department = await fastify.prisma.department.findUnique({
|
await ensureDepartmentUsable(fastify.prisma, departmentId);
|
||||||
where: { id: departmentId },
|
|
||||||
select: { id: true, archived_at: true },
|
|
||||||
});
|
|
||||||
if (!department) {
|
|
||||||
throw new AppError({
|
|
||||||
code: "VALID_001",
|
|
||||||
message: "参数校验失败",
|
|
||||||
statusCode: 400,
|
|
||||||
details: { departmentId: "not_found" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (department.archived_at) {
|
|
||||||
throw new AppError({
|
|
||||||
code: "VALID_001",
|
|
||||||
message: "参数校验失败",
|
|
||||||
statusCode: 409,
|
|
||||||
details: { departmentId: "archived" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof body.directManagerId === "string") {
|
if (typeof body.directManagerId === "string") {
|
||||||
const manager = await fastify.prisma.employee.findUnique({
|
await ensureManagerUsable(fastify.prisma, body.directManagerId);
|
||||||
where: { id: body.directManagerId },
|
|
||||||
select: { id: true, status: true },
|
|
||||||
});
|
|
||||||
if (!manager) {
|
|
||||||
throw new AppError({
|
|
||||||
code: "VALID_001",
|
|
||||||
message: "参数校验失败",
|
|
||||||
statusCode: 400,
|
|
||||||
details: { directManagerId: "not_found" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (manager.status !== "active") {
|
|
||||||
throw new AppError({
|
|
||||||
code: "VALID_001",
|
|
||||||
message: "参数校验失败",
|
|
||||||
statusCode: 409,
|
|
||||||
details: { directManagerId: "inactive" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -980,6 +1118,192 @@ async function usersRoutes(fastify, opts = {}) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (enableBatchActions) {
|
||||||
|
fastify.post(
|
||||||
|
`${routeBasePath}/batch/transfer-department`,
|
||||||
|
{
|
||||||
|
preHandler: fastify.requirePermission("system.users"),
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
await ensureLifecycleFeatureEnabled(fastify, request);
|
||||||
|
const body = parseBatchBody(BATCH_TRANSFER_DEPARTMENT_SCHEMA, request.body);
|
||||||
|
await ensureDepartmentUsable(fastify.prisma, body.departmentId);
|
||||||
|
|
||||||
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
|
await ensureEmployeesExist(tx, body.employeeIds);
|
||||||
|
|
||||||
|
for (const employeeId of body.employeeIds) {
|
||||||
|
await applyEmployeeTransfer(tx, {
|
||||||
|
employeeId,
|
||||||
|
actorId: request.employee.id,
|
||||||
|
departmentId: body.departmentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId: request.employee.id,
|
||||||
|
action: "SYSTEM_USERS_BATCH_TRANSFER_DEPARTMENT",
|
||||||
|
targetType: "employee_batch",
|
||||||
|
extra: {
|
||||||
|
employeeIds: body.employeeIds,
|
||||||
|
departmentId: body.departmentId,
|
||||||
|
summary: {
|
||||||
|
total: body.employeeIds.length,
|
||||||
|
updated: body.employeeIds.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return sendData(reply, request, {
|
||||||
|
summary: { total: body.employeeIds.length, updated: body.employeeIds.length },
|
||||||
|
employeeIds: body.employeeIds,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
`${routeBasePath}/batch/set-manager`,
|
||||||
|
{
|
||||||
|
preHandler: fastify.requirePermission("system.users"),
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
await ensureLifecycleFeatureEnabled(fastify, request);
|
||||||
|
const body = parseBatchBody(BATCH_SET_MANAGER_SCHEMA, request.body);
|
||||||
|
await ensureManagerUsable(fastify.prisma, body.managerId, {
|
||||||
|
fieldName: "managerId",
|
||||||
|
disallowedEmployeeIds: body.employeeIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
|
await ensureEmployeesExist(tx, body.employeeIds);
|
||||||
|
|
||||||
|
for (const employeeId of body.employeeIds) {
|
||||||
|
await applyEmployeeTransfer(tx, {
|
||||||
|
employeeId,
|
||||||
|
actorId: request.employee.id,
|
||||||
|
directManagerId: body.managerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId: request.employee.id,
|
||||||
|
action: "SYSTEM_USERS_BATCH_SET_MANAGER",
|
||||||
|
targetType: "employee_batch",
|
||||||
|
extra: {
|
||||||
|
employeeIds: body.employeeIds,
|
||||||
|
managerId: body.managerId,
|
||||||
|
summary: {
|
||||||
|
total: body.employeeIds.length,
|
||||||
|
updated: body.employeeIds.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return sendData(reply, request, {
|
||||||
|
summary: { total: body.employeeIds.length, updated: body.employeeIds.length },
|
||||||
|
employeeIds: body.employeeIds,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
`${routeBasePath}/batch/disable`,
|
||||||
|
{
|
||||||
|
preHandler: fastify.requirePermission("system.users"),
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const body = parseBatchBody(BATCH_TOGGLE_SCHEMA, request.body);
|
||||||
|
if (body.employeeIds.includes(request.employee.id)) {
|
||||||
|
throw buildValidationError({ user: "self_disable_not_allowed" }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
|
await ensureEmployeesExist(tx, body.employeeIds);
|
||||||
|
|
||||||
|
for (const employeeId of body.employeeIds) {
|
||||||
|
await setEmployeeAccountStatus(tx, request, {
|
||||||
|
employeeId,
|
||||||
|
actorId: request.employee.id,
|
||||||
|
status: "archived",
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId: request.employee.id,
|
||||||
|
action: "SYSTEM_USERS_BATCH_DISABLED",
|
||||||
|
targetType: "employee_batch",
|
||||||
|
extra: {
|
||||||
|
employeeIds: body.employeeIds,
|
||||||
|
summary: {
|
||||||
|
total: body.employeeIds.length,
|
||||||
|
updated: body.employeeIds.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return sendData(reply, request, {
|
||||||
|
summary: { total: body.employeeIds.length, updated: body.employeeIds.length },
|
||||||
|
employeeIds: body.employeeIds,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
`${routeBasePath}/batch/enable`,
|
||||||
|
{
|
||||||
|
preHandler: fastify.requirePermission("system.users"),
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const body = parseBatchBody(BATCH_TOGGLE_SCHEMA, request.body);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
|
await ensureEmployeesExist(tx, body.employeeIds);
|
||||||
|
|
||||||
|
for (const employeeId of body.employeeIds) {
|
||||||
|
await setEmployeeAccountStatus(tx, request, {
|
||||||
|
employeeId,
|
||||||
|
actorId: request.employee.id,
|
||||||
|
status: "active",
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId: request.employee.id,
|
||||||
|
action: "SYSTEM_USERS_BATCH_ENABLED",
|
||||||
|
targetType: "employee_batch",
|
||||||
|
extra: {
|
||||||
|
employeeIds: body.employeeIds,
|
||||||
|
summary: {
|
||||||
|
total: body.employeeIds.length,
|
||||||
|
updated: body.employeeIds.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return sendData(reply, request, {
|
||||||
|
summary: { total: body.employeeIds.length, updated: body.employeeIds.length },
|
||||||
|
employeeIds: body.employeeIds,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (routeBasePath === "/users") {
|
if (routeBasePath === "/users") {
|
||||||
fastify.post(
|
fastify.post(
|
||||||
`${routeBasePath}/import/dry-run`,
|
`${routeBasePath}/import/dry-run`,
|
||||||
@@ -1146,59 +1470,14 @@ async function usersRoutes(fastify, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof body.departmentId === "string") {
|
if (typeof body.departmentId === "string") {
|
||||||
const department = await fastify.prisma.department.findUnique({
|
await ensureDepartmentUsable(fastify.prisma, body.departmentId);
|
||||||
where: { id: body.departmentId },
|
|
||||||
select: { id: true, archived_at: true },
|
|
||||||
});
|
|
||||||
if (!department) {
|
|
||||||
throw new AppError({
|
|
||||||
code: "VALID_001",
|
|
||||||
message: "参数校验失败",
|
|
||||||
statusCode: 400,
|
|
||||||
details: { departmentId: "not_found" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (department.archived_at) {
|
|
||||||
throw new AppError({
|
|
||||||
code: "VALID_001",
|
|
||||||
message: "参数校验失败",
|
|
||||||
statusCode: 409,
|
|
||||||
details: { departmentId: "archived" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.prototype.hasOwnProperty.call(body, "directManagerId")) {
|
if (Object.prototype.hasOwnProperty.call(body, "directManagerId")) {
|
||||||
if (body.directManagerId === userId) {
|
|
||||||
throw new AppError({
|
|
||||||
code: "VALID_001",
|
|
||||||
message: "参数校验失败",
|
|
||||||
statusCode: 409,
|
|
||||||
details: { directManagerId: "self_not_allowed" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof body.directManagerId === "string") {
|
if (typeof body.directManagerId === "string") {
|
||||||
const manager = await fastify.prisma.employee.findUnique({
|
await ensureManagerUsable(fastify.prisma, body.directManagerId, {
|
||||||
where: { id: body.directManagerId },
|
disallowedEmployeeIds: [userId],
|
||||||
select: { id: true, status: true },
|
|
||||||
});
|
});
|
||||||
if (!manager) {
|
|
||||||
throw new AppError({
|
|
||||||
code: "VALID_001",
|
|
||||||
message: "参数校验失败",
|
|
||||||
statusCode: 400,
|
|
||||||
details: { directManagerId: "not_found" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (manager.status !== "active") {
|
|
||||||
throw new AppError({
|
|
||||||
code: "VALID_001",
|
|
||||||
message: "参数校验失败",
|
|
||||||
statusCode: 409,
|
|
||||||
details: { directManagerId: "inactive" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1605,37 +1884,12 @@ async function usersRoutes(fastify, opts = {}) {
|
|||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const employee = await fastify.prisma.$transaction(async (tx) => {
|
const employee = await fastify.prisma.$transaction(async (tx) => {
|
||||||
const updated = await tx.employee.update({
|
return setEmployeeAccountStatus(tx, request, {
|
||||||
where: { id: userId },
|
employeeId: userId,
|
||||||
data: {
|
actorId: request.employee.id,
|
||||||
status: "archived",
|
status: "archived",
|
||||||
archived_at: now,
|
now,
|
||||||
token_version: { increment: 1 },
|
|
||||||
updated_by: request.employee.id,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
status: true,
|
|
||||||
token_version: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.authSession.updateMany({
|
|
||||||
where: { employee_id: userId, revoked_at: null },
|
|
||||||
data: { revoked_at: now },
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.auditLog.create({
|
|
||||||
data: buildAuditLogData(request, {
|
|
||||||
actorId: request.employee.id,
|
|
||||||
action: "SYSTEM_USER_DISABLED",
|
|
||||||
targetType: "employee",
|
|
||||||
targetId: userId,
|
|
||||||
extra: {},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return updated;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return sendData(reply, request, {
|
return sendData(reply, request, {
|
||||||
@@ -1655,37 +1909,12 @@ async function usersRoutes(fastify, opts = {}) {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const employee = await fastify.prisma.$transaction(async (tx) => {
|
const employee = await fastify.prisma.$transaction(async (tx) => {
|
||||||
const updated = await tx.employee.update({
|
return setEmployeeAccountStatus(tx, request, {
|
||||||
where: { id: userId },
|
employeeId: userId,
|
||||||
data: {
|
actorId: request.employee.id,
|
||||||
status: "active",
|
status: "active",
|
||||||
archived_at: null,
|
now,
|
||||||
token_version: { increment: 1 },
|
|
||||||
updated_by: request.employee.id,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
status: true,
|
|
||||||
token_version: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.authSession.updateMany({
|
|
||||||
where: { employee_id: userId, revoked_at: null },
|
|
||||||
data: { revoked_at: now },
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.auditLog.create({
|
|
||||||
data: buildAuditLogData(request, {
|
|
||||||
actorId: request.employee.id,
|
|
||||||
action: "SYSTEM_USER_ENABLED",
|
|
||||||
targetType: "employee",
|
|
||||||
targetId: userId,
|
|
||||||
extra: {},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return updated;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return sendData(reply, request, {
|
return sendData(reply, request, {
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
const { z } = require("zod");
|
||||||
|
|
||||||
|
const { AppError } = require("../utils/AppError");
|
||||||
|
|
||||||
|
const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
const EMPLOYMENT_STATUS_VALUES = ["onboarding", "probation", "active", "offboarding", "inactive"];
|
||||||
|
|
||||||
|
function buildValidationError(details, statusCode = 400) {
|
||||||
|
return new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode,
|
||||||
|
details,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEmployeeDirectoryFilters(query, options = {}) {
|
||||||
|
const allowDepartmentFilter = options.allowDepartmentFilter === true;
|
||||||
|
const schema = z.object({
|
||||||
|
status: z.enum(["active", "archived"]).optional(),
|
||||||
|
q: z.string().max(100).optional(),
|
||||||
|
title: z.string().max(100).optional(),
|
||||||
|
managerId: z.string().uuid().optional(),
|
||||||
|
hireDateFrom: DATE_ONLY_SCHEMA.optional(),
|
||||||
|
hireDateTo: DATE_ONLY_SCHEMA.optional(),
|
||||||
|
employmentStatus: z.enum(EMPLOYMENT_STATUS_VALUES).optional(),
|
||||||
|
...(allowDepartmentFilter ? { departmentId: z.string().uuid().optional() } : {}),
|
||||||
|
});
|
||||||
|
const parsed = schema.safeParse(query ?? {});
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw buildValidationError(parsed.error.flatten());
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
status: parsed.data.status,
|
||||||
|
q: parsed.data.q?.trim() ?? "",
|
||||||
|
title: parsed.data.title?.trim() ?? "",
|
||||||
|
managerId: parsed.data.managerId,
|
||||||
|
hireDateFrom: parsed.data.hireDateFrom,
|
||||||
|
hireDateTo: parsed.data.hireDateTo,
|
||||||
|
employmentStatus: parsed.data.employmentStatus,
|
||||||
|
departmentId: allowDepartmentFilter ? parsed.data.departmentId : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (filters.hireDateFrom && filters.hireDateTo && filters.hireDateFrom > filters.hireDateTo) {
|
||||||
|
throw buildValidationError({ hireDateTo: "must_be_after_or_equal_hireDateFrom" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmployeeDirectoryWhere(filters = {}) {
|
||||||
|
const q = filters.q?.trim() ?? "";
|
||||||
|
const title = filters.title?.trim() ?? "";
|
||||||
|
const and = [];
|
||||||
|
|
||||||
|
if (q) {
|
||||||
|
and.push({
|
||||||
|
OR: [
|
||||||
|
{ employee_no: { contains: q, mode: "insensitive" } },
|
||||||
|
{ name: { contains: q, mode: "insensitive" } },
|
||||||
|
{ phone: { contains: q, mode: "insensitive" } },
|
||||||
|
{ email: { contains: q, mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
and.push({
|
||||||
|
title: { contains: title, mode: "insensitive" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(filters.status ? { status: filters.status } : {}),
|
||||||
|
...(filters.departmentId ? { department_id: filters.departmentId } : {}),
|
||||||
|
...(filters.managerId ? { direct_manager_id: filters.managerId } : {}),
|
||||||
|
...(filters.employmentStatus ? { employment_status: filters.employmentStatus } : {}),
|
||||||
|
...(filters.hireDateFrom || filters.hireDateTo
|
||||||
|
? {
|
||||||
|
hire_date: {
|
||||||
|
...(filters.hireDateFrom ? { gte: new Date(`${filters.hireDateFrom}T00:00:00.000Z`) } : {}),
|
||||||
|
...(filters.hireDateTo ? { lte: new Date(`${filters.hireDateTo}T23:59:59.999Z`) } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(and.length ? { AND: and } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DATE_ONLY_SCHEMA,
|
||||||
|
EMPLOYMENT_STATUS_VALUES,
|
||||||
|
buildEmployeeDirectoryWhere,
|
||||||
|
buildValidationError,
|
||||||
|
parseEmployeeDirectoryFilters,
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
function resolveEffectiveDate(rawValue) {
|
||||||
|
return rawValue ? new Date(`${rawValue}T00:00:00.000Z`) : new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDefaultChecklist(scene, employeeId) {
|
||||||
|
const common = { employee_id: employeeId };
|
||||||
|
|
||||||
|
if (scene === "onboarding") {
|
||||||
|
return [
|
||||||
|
{ ...common, scene, task_type: "account_setup", title: "完成账号与权限初始化", status: "pending" },
|
||||||
|
{ ...common, scene, task_type: "asset_issue", title: "确认办公资产发放", status: "pending" },
|
||||||
|
{ ...common, scene, task_type: "policy_ack", title: "确认制度与入职须知", status: "pending" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scene === "transfer") {
|
||||||
|
return [{ ...common, scene, task_type: "handover_note", title: "确认调岗交接说明", status: "pending" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ ...common, scene, task_type: "asset_return", title: "确认资产归还/转交", status: "pending" },
|
||||||
|
{ ...common, scene, task_type: "access_revoke", title: "确认账号和权限回收", status: "pending" },
|
||||||
|
{ ...common, scene, task_type: "expense_clearance", title: "确认费用与审批待办已清理", status: "pending" },
|
||||||
|
{ ...common, scene, task_type: "handover_note", title: "确认离职交接说明", status: "pending" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyEmployeeTransfer(tx, options) {
|
||||||
|
await tx.employee.update({
|
||||||
|
where: { id: options.employeeId },
|
||||||
|
data: {
|
||||||
|
...(options.departmentId !== undefined ? { department_id: options.departmentId } : {}),
|
||||||
|
...(options.directManagerId !== undefined ? { direct_manager_id: options.directManagerId } : {}),
|
||||||
|
...(options.title !== undefined ? { title: options.title } : {}),
|
||||||
|
updated_by: options.actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.employeeLifecycleEvent.create({
|
||||||
|
data: {
|
||||||
|
employee_id: options.employeeId,
|
||||||
|
event_type: "transfer",
|
||||||
|
effective_date: resolveEffectiveDate(options.effectiveDate),
|
||||||
|
payload_json: {
|
||||||
|
departmentId: options.departmentId ?? null,
|
||||||
|
directManagerId: options.directManagerId ?? null,
|
||||||
|
title: options.title ?? null,
|
||||||
|
notes: options.notes ?? null,
|
||||||
|
},
|
||||||
|
created_by: options.actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.employeeChecklistTask.createMany({
|
||||||
|
data: buildDefaultChecklist("transfer", options.employeeId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
applyEmployeeTransfer,
|
||||||
|
buildDefaultChecklist,
|
||||||
|
resolveEffectiveDate,
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
const { currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||||
|
|
||||||
|
function addDays(date, days) {
|
||||||
|
const next = new Date(date);
|
||||||
|
next.setUTCDate(next.getUTCDate() + days);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildEmployeeStats(prisma, options = {}) {
|
||||||
|
const timeZone = options.timeZone ?? "Asia/Shanghai";
|
||||||
|
const todayString = currentDateString(timeZone);
|
||||||
|
const today = parseDateOnlyString(todayString);
|
||||||
|
const last30DateFrom = addDays(today, -29);
|
||||||
|
const next30DateTo = addDays(today, 30);
|
||||||
|
const activeEmployeeWhere = { status: "active" };
|
||||||
|
|
||||||
|
const [employmentStatusRows, departmentRows, departments, recentHires, recentDepartures, expiringContracts] = await Promise.all([
|
||||||
|
prisma.employee.groupBy({
|
||||||
|
by: ["employment_status"],
|
||||||
|
where: activeEmployeeWhere,
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
prisma.employee.groupBy({
|
||||||
|
by: ["department_id"],
|
||||||
|
where: activeEmployeeWhere,
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
prisma.department.findMany({
|
||||||
|
where: { archived_at: null },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
}),
|
||||||
|
prisma.employee.count({
|
||||||
|
where: {
|
||||||
|
hire_date: {
|
||||||
|
gte: last30DateFrom,
|
||||||
|
lte: new Date(`${todayString}T23:59:59.999Z`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.employee.count({
|
||||||
|
where: {
|
||||||
|
last_working_date: {
|
||||||
|
gte: last30DateFrom,
|
||||||
|
lte: new Date(`${todayString}T23:59:59.999Z`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.employeeContract.count({
|
||||||
|
where: {
|
||||||
|
terminated_date: null,
|
||||||
|
end_date: {
|
||||||
|
gte: today,
|
||||||
|
lte: new Date(`${next30DateTo.toISOString().slice(0, 10)}T23:59:59.999Z`),
|
||||||
|
},
|
||||||
|
employee: activeEmployeeWhere,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const departmentMap = new Map(departments.map((item) => [item.id, item.name]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
asOfDate: todayString,
|
||||||
|
employmentStatusSummary: {
|
||||||
|
total: employmentStatusRows.reduce((sum, item) => sum + item._count._all, 0),
|
||||||
|
items: employmentStatusRows
|
||||||
|
.map((item) => ({
|
||||||
|
status: item.employment_status,
|
||||||
|
count: item._count._all,
|
||||||
|
}))
|
||||||
|
.sort((left, right) => right.count - left.count || left.status.localeCompare(right.status)),
|
||||||
|
},
|
||||||
|
departmentHeadcount: departmentRows
|
||||||
|
.map((item) => ({
|
||||||
|
departmentId: item.department_id,
|
||||||
|
departmentName: departmentMap.get(item.department_id) ?? "未知部门",
|
||||||
|
count: item._count._all,
|
||||||
|
}))
|
||||||
|
.sort((left, right) => right.count - left.count || left.departmentName.localeCompare(right.departmentName)),
|
||||||
|
recentMovements: {
|
||||||
|
dateFrom: last30DateFrom.toISOString().slice(0, 10),
|
||||||
|
dateTo: todayString,
|
||||||
|
hires: recentHires,
|
||||||
|
departures: recentDepartures,
|
||||||
|
},
|
||||||
|
expiringContracts: {
|
||||||
|
dateFrom: todayString,
|
||||||
|
dateTo: next30DateTo.toISOString().slice(0, 10),
|
||||||
|
count: expiringContracts,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildEmployeeStats,
|
||||||
|
};
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
const Fastify = require("fastify");
|
||||||
|
|
||||||
|
const employeesRoutes = require("../../../src/routes/employees");
|
||||||
|
const { AppError } = require("../../../src/utils/AppError");
|
||||||
|
|
||||||
|
async function buildApp({ permissionCodes = ["system.users"], stats } = {}) {
|
||||||
|
const app = Fastify();
|
||||||
|
const employeeGroupBy = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ employment_status: "active", _count: { _all: 8 } },
|
||||||
|
{ employment_status: "probation", _count: { _all: 2 } },
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", _count: { _all: 6 } },
|
||||||
|
{ department_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", _count: { _all: 4 } },
|
||||||
|
]);
|
||||||
|
const employeeCount = vi.fn().mockResolvedValueOnce(3).mockResolvedValueOnce(1);
|
||||||
|
const departmentFindMany = vi.fn().mockResolvedValue([
|
||||||
|
{ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", name: "行政部" },
|
||||||
|
{ id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", name: "研发部" },
|
||||||
|
]);
|
||||||
|
const contractCount = vi.fn().mockResolvedValue(2);
|
||||||
|
|
||||||
|
app.decorate("prisma", {
|
||||||
|
employee: {
|
||||||
|
groupBy: stats?.employeeGroupBy ?? employeeGroupBy,
|
||||||
|
count: stats?.employeeCount ?? employeeCount,
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
department: {
|
||||||
|
findMany: stats?.departmentFindMany ?? departmentFindMany,
|
||||||
|
},
|
||||||
|
employeeContract: {
|
||||||
|
count: stats?.employeeContractCount ?? contractCount,
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
app.decorate("authenticate", async (request) => {
|
||||||
|
request.employee = {
|
||||||
|
id: "actor-1",
|
||||||
|
department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
permissionCodes,
|
||||||
|
roleCodes: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
||||||
|
request.employee = {
|
||||||
|
id: "actor-1",
|
||||||
|
department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
permissionCodes,
|
||||||
|
roleCodes: [],
|
||||||
|
};
|
||||||
|
if (!permissionCodes.includes("*") && !permissionCodes.includes(permissionCode)) {
|
||||||
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.decorate("hasPermission", (employee, permissionCode) => {
|
||||||
|
const codes = employee.permissionCodes ?? [];
|
||||||
|
return codes.includes("*") || codes.includes(permissionCode);
|
||||||
|
});
|
||||||
|
app.decorate("config", { appTimezone: "Asia/Shanghai" });
|
||||||
|
app.setErrorHandler((error, request, reply) => {
|
||||||
|
reply
|
||||||
|
.status(error.statusCode || 500)
|
||||||
|
.send({ code: error.code || "ERR", message: error.message, details: error.details ?? null });
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(employeesRoutes, { prefix: "/api" });
|
||||||
|
return { app, employeeGroupBy, employeeCount, departmentFindMany, contractCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("employees stats route", () => {
|
||||||
|
it("returns aggregated employee stats for read-only viewers", async () => {
|
||||||
|
const { app, employeeGroupBy, employeeCount, departmentFindMany, contractCount } = await buildApp({
|
||||||
|
permissionCodes: ["report.dashboard.view"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/employees/stats",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const payload = response.json();
|
||||||
|
expect(payload.data.employmentStatusSummary.total).toBe(10);
|
||||||
|
expect(payload.data.departmentHeadcount).toEqual([
|
||||||
|
{ departmentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", departmentName: "行政部", count: 6 },
|
||||||
|
{ departmentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", departmentName: "研发部", count: 4 },
|
||||||
|
]);
|
||||||
|
expect(payload.data.recentMovements.hires).toBe(3);
|
||||||
|
expect(payload.data.recentMovements.departures).toBe(1);
|
||||||
|
expect(payload.data.expiringContracts.count).toBe(2);
|
||||||
|
expect(employeeGroupBy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(employeeCount).toHaveBeenCalledTimes(2);
|
||||||
|
expect(departmentFindMany).toHaveBeenCalledTimes(1);
|
||||||
|
expect(contractCount).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects requests without people directory permission", async () => {
|
||||||
|
const { app } = await buildApp({ permissionCodes: ["employee"] });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/employees/stats",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(403);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -62,7 +62,7 @@ describe("employee exports route", () => {
|
|||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: `/api/exports/employees?format=csv&status=active&departmentId=${DEPARTMENT_ID}&q=alice`,
|
url: `/api/exports/employees?format=csv&status=active&departmentId=${DEPARTMENT_ID}&q=alice&title=%E7%BB%8F%E7%90%86&managerId=bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb&hireDateFrom=2026-01-01&hireDateTo=2026-01-31&employmentStatus=active`,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
@@ -73,9 +73,22 @@ describe("employee exports route", () => {
|
|||||||
where: expect.objectContaining({
|
where: expect.objectContaining({
|
||||||
status: "active",
|
status: "active",
|
||||||
department_id: DEPARTMENT_ID,
|
department_id: DEPARTMENT_ID,
|
||||||
OR: expect.arrayContaining([
|
direct_manager_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||||
expect.objectContaining({ name: expect.any(Object) }),
|
employment_status: "active",
|
||||||
expect.objectContaining({ employee_no: expect.any(Object) }),
|
hire_date: expect.objectContaining({
|
||||||
|
gte: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
lte: new Date("2026-01-31T23:59:59.999Z"),
|
||||||
|
}),
|
||||||
|
AND: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
OR: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ name: expect.any(Object) }),
|
||||||
|
expect.objectContaining({ employee_no: expect.any(Object) }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
title: expect.objectContaining({ contains: "经理" }),
|
||||||
|
}),
|
||||||
]),
|
]),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -84,12 +97,20 @@ describe("employee exports route", () => {
|
|||||||
where: expect.objectContaining({
|
where: expect.objectContaining({
|
||||||
status: "active",
|
status: "active",
|
||||||
department_id: DEPARTMENT_ID,
|
department_id: DEPARTMENT_ID,
|
||||||
|
direct_manager_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(auditCreate).toHaveBeenCalledWith(
|
expect(auditCreate).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
action: "EXPORT_EMPLOYEES",
|
action: "EXPORT_EMPLOYEES",
|
||||||
|
extra: expect.objectContaining({
|
||||||
|
title: "经理",
|
||||||
|
managerId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||||
|
hireDateFrom: "2026-01-01",
|
||||||
|
hireDateTo: "2026-01-31",
|
||||||
|
employmentStatus: "active",
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const Fastify = require("fastify");
|
const Fastify = require("fastify");
|
||||||
|
|
||||||
const usersRoutes = require("../../../src/routes/users");
|
const usersRoutes = require("../../../src/routes/users");
|
||||||
|
const { AppError } = require("../../../src/utils/AppError");
|
||||||
|
|
||||||
function buildEmployeeRow(overrides = {}) {
|
function buildEmployeeRow(overrides = {}) {
|
||||||
const now = new Date("2026-03-28T10:00:00.000Z");
|
const now = new Date("2026-03-28T10:00:00.000Z");
|
||||||
@@ -20,10 +21,17 @@ function buildEmployeeRow(overrides = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildApp(routeOptions = {}) {
|
async function buildApp({ routeOptions = {}, employee } = {}) {
|
||||||
const app = Fastify();
|
const app = Fastify();
|
||||||
const findMany = vi.fn().mockResolvedValue([buildEmployeeRow()]);
|
const findMany = vi.fn().mockResolvedValue([buildEmployeeRow()]);
|
||||||
const count = vi.fn().mockResolvedValue(1);
|
const count = vi.fn().mockResolvedValue(1);
|
||||||
|
const actor =
|
||||||
|
employee ??
|
||||||
|
{
|
||||||
|
id: "actor-1",
|
||||||
|
department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
permissionCodes: ["system.users"],
|
||||||
|
};
|
||||||
|
|
||||||
app.decorate("prisma", {
|
app.decorate("prisma", {
|
||||||
employee: {
|
employee: {
|
||||||
@@ -31,21 +39,20 @@ async function buildApp(routeOptions = {}) {
|
|||||||
count,
|
count,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
app.decorate("requirePermission", () => async (request) => {
|
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
||||||
request.employee = {
|
request.employee = actor;
|
||||||
id: "actor-1",
|
const permissionCodes = actor.permissionCodes ?? [];
|
||||||
department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
if (!permissionCodes.includes("*") && !permissionCodes.includes(permissionCode)) {
|
||||||
permissionCodes: ["system.users"],
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||||
};
|
}
|
||||||
});
|
});
|
||||||
app.decorate("authenticate", async (request) => {
|
app.decorate("authenticate", async (request) => {
|
||||||
request.employee = {
|
request.employee = actor;
|
||||||
id: "actor-1",
|
});
|
||||||
department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
app.decorate("hasPermission", (requestEmployee, permissionCode) => {
|
||||||
permissionCodes: ["system.users"],
|
const permissionCodes = requestEmployee.permissionCodes ?? [];
|
||||||
};
|
return permissionCodes.includes("*") || permissionCodes.includes(permissionCode);
|
||||||
});
|
});
|
||||||
app.decorate("hasPermission", () => true);
|
|
||||||
app.decorate("config", {});
|
app.decorate("config", {});
|
||||||
|
|
||||||
await app.register(usersRoutes, { prefix: "/api", ...routeOptions });
|
await app.register(usersRoutes, { prefix: "/api", ...routeOptions });
|
||||||
@@ -56,8 +63,10 @@ describe("usersRoutes list query", () => {
|
|||||||
it("supports departmentId filter for /employees when enabled", async () => {
|
it("supports departmentId filter for /employees when enabled", async () => {
|
||||||
const departmentId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
const departmentId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||||
const { app, findMany, count } = await buildApp({
|
const { app, findMany, count } = await buildApp({
|
||||||
basePath: "/employees",
|
routeOptions: {
|
||||||
allowDepartmentFilter: true,
|
basePath: "/employees",
|
||||||
|
allowDepartmentFilter: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
@@ -85,8 +94,10 @@ describe("usersRoutes list query", () => {
|
|||||||
it("combines q + status + departmentId for /employees", async () => {
|
it("combines q + status + departmentId for /employees", async () => {
|
||||||
const departmentId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
|
const departmentId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
|
||||||
const { app, findMany } = await buildApp({
|
const { app, findMany } = await buildApp({
|
||||||
basePath: "/employees",
|
routeOptions: {
|
||||||
allowDepartmentFilter: true,
|
basePath: "/employees",
|
||||||
|
allowDepartmentFilter: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
@@ -100,9 +111,13 @@ describe("usersRoutes list query", () => {
|
|||||||
where: expect.objectContaining({
|
where: expect.objectContaining({
|
||||||
status: "active",
|
status: "active",
|
||||||
department_id: departmentId,
|
department_id: departmentId,
|
||||||
OR: expect.arrayContaining([
|
AND: expect.arrayContaining([
|
||||||
expect.objectContaining({ name: expect.any(Object) }),
|
expect.objectContaining({
|
||||||
expect.objectContaining({ employee_no: expect.any(Object) }),
|
OR: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ name: expect.any(Object) }),
|
||||||
|
expect.objectContaining({ employee_no: expect.any(Object) }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
]),
|
]),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -111,6 +126,58 @@ describe("usersRoutes list query", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("supports advanced filters and report.dashboard.view for /employees", async () => {
|
||||||
|
const departmentId = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee";
|
||||||
|
const managerId = "ffffffff-ffff-4fff-8fff-ffffffffffff";
|
||||||
|
const { app, findMany, count } = await buildApp({
|
||||||
|
routeOptions: {
|
||||||
|
basePath: "/employees",
|
||||||
|
allowDepartmentFilter: true,
|
||||||
|
listPermissionCodes: ["system.users", "report.dashboard.view"],
|
||||||
|
},
|
||||||
|
employee: {
|
||||||
|
id: "viewer-1",
|
||||||
|
department_id: departmentId,
|
||||||
|
permissionCodes: ["report.dashboard.view"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/employees?page=1&pageSize=20&departmentId=${departmentId}&title=%E7%BB%8F%E7%90%86&managerId=${managerId}&hireDateFrom=2026-01-01&hireDateTo=2026-01-31&employmentStatus=probation`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
department_id: departmentId,
|
||||||
|
direct_manager_id: managerId,
|
||||||
|
employment_status: "probation",
|
||||||
|
hire_date: expect.objectContaining({
|
||||||
|
gte: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
lte: new Date("2026-01-31T23:59:59.999Z"),
|
||||||
|
}),
|
||||||
|
AND: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
title: expect.objectContaining({
|
||||||
|
contains: "经理",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(count).toHaveBeenCalledWith({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
department_id: departmentId,
|
||||||
|
direct_manager_id: managerId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not apply departmentId filter for /users", async () => {
|
it("does not apply departmentId filter for /users", async () => {
|
||||||
const departmentId = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
|
const departmentId = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
|
||||||
const { app, findMany } = await buildApp();
|
const { app, findMany } = await buildApp();
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户