diff --git a/client/src/api/departments.ts b/client/src/api/departments.ts new file mode 100644 index 00000000..64918d81 --- /dev/null +++ b/client/src/api/departments.ts @@ -0,0 +1,52 @@ +import { apiRequestData } from "./http"; + +export type DepartmentItem = { + id: string; + name: string; + parentId: string | null; + archivedAt: string | null; + createdAt: string | null; + updatedAt: string | null; +}; + +export type DepartmentTreeNode = DepartmentItem & { + children: DepartmentTreeNode[]; +}; + +export type DepartmentListResponse = { + includeArchived: boolean; + items: DepartmentItem[]; + tree: DepartmentTreeNode[]; +}; + +export async function fetchDepartments(params: { includeArchived?: boolean } = {}) { + const search = new URLSearchParams(); + if (params.includeArchived != null) { + search.set("includeArchived", params.includeArchived ? "1" : "0"); + } + + const qs = search.toString(); + return apiRequestData(`/api/departments${qs ? `?${qs}` : ""}`); +} + +export async function createDepartment(payload: { name: string; parentId?: string | null }) { + return apiRequestData("/api/departments", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function updateDepartment(departmentId: string, payload: { name?: string; parentId?: string | null }) { + return apiRequestData(`/api/departments/${departmentId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + +export async function archiveDepartment(departmentId: string) { + return apiRequestData(`/api/departments/${departmentId}/archive`, { + method: "POST", + body: JSON.stringify({}), + }); +} + diff --git a/client/src/api/employees.ts b/client/src/api/employees.ts new file mode 100644 index 00000000..bba5d8ed --- /dev/null +++ b/client/src/api/employees.ts @@ -0,0 +1,41 @@ +import { apiRequestData } from "./http"; + +export type EmployeeStatus = "active" | "archived"; + +export type EmployeeSummary = { + id: string; + employeeNo: string; + name: string; + phone: string; + email: string; + status: EmployeeStatus; + departmentId: string; + directManagerId: string | null; + title: string; + roleCodes: string[]; + createdAt: string | null; + updatedAt: string | null; +}; + +export type EmployeeListResponse = { + page: number; + pageSize: number; + total: number; + items: EmployeeSummary[]; +}; + +export async function fetchEmployees(params: { + page: number; + pageSize: number; + status?: EmployeeStatus; + q?: string; +}) { + const search = new URLSearchParams(); + search.set("page", String(params.page)); + search.set("pageSize", String(params.pageSize)); + if (params.status) search.set("status", params.status); + if (params.q) search.set("q", params.q); + + return apiRequestData(`/api/employees?${search.toString()}`); +} + diff --git a/client/src/api/users.ts b/client/src/api/users.ts index 3b2d6ed6..82927228 100644 --- a/client/src/api/users.ts +++ b/client/src/api/users.ts @@ -44,6 +44,8 @@ export async function createUser(payload: { phone: string; email: string; password: string; + departmentId?: string; + directManagerId?: string | null; roles: string[]; }) { return apiRequestData("/api/users", { @@ -80,4 +82,3 @@ export async function setUserRoles(userId: string, roles: string[]) { body: JSON.stringify({ roles }), }); } - diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index 1b4033c3..fda60de3 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -41,6 +41,11 @@ export const NAV_ITEMS: NavItem[] = [ label: "用户管理", anyOf: ["system.users"], }, + { + to: "/system/departments", + label: "部门管理", + anyOf: ["system.users"], + }, { to: "/settings/profile", label: "个人设置" }, ]; diff --git a/client/src/pages/DepartmentsPage.tsx b/client/src/pages/DepartmentsPage.tsx new file mode 100644 index 00000000..0db545bf --- /dev/null +++ b/client/src/pages/DepartmentsPage.tsx @@ -0,0 +1,242 @@ +import { useMemo, useState } from "react"; +import { Link, useSearchParams } from "react-router-dom"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { mapApiErrorMessage } from "../api/errorMessages"; +import { isApiError } from "../api/types"; +import { + archiveDepartment, + createDepartment, + fetchDepartments, + type DepartmentItem, +} from "../api/departments"; +import { PageHeader } from "../components/common/PageHeader"; +import { Button } from "../components/ui/button"; +import { Card } from "../components/ui/card"; +import { Input } from "../components/ui/input"; +import { Label } from "../components/ui/label"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; + +export function DepartmentsPage() { + const queryClient = useQueryClient(); + const [searchParams, setSearchParams] = useSearchParams(); + + const includeArchivedRaw = searchParams.get("includeArchived") ?? "0"; + const includeArchived = includeArchivedRaw === "1" || includeArchivedRaw === "true"; + + const [name, setName] = useState(""); + const [parentId, setParentId] = useState(""); + const [error, setError] = useState(""); + + const listQuery = useQuery({ + queryKey: ["departments", includeArchived], + queryFn: () => fetchDepartments({ includeArchived }), + }); + + const departments = listQuery.data?.items ?? []; + + const departmentById = useMemo(() => { + const map = new Map(); + for (const dept of departments) { + map.set(dept.id, dept); + } + return map; + }, [departments]); + + const createMutation = useMutation({ + mutationFn: () => + createDepartment({ + name: name.trim(), + parentId: parentId ? parentId : null, + }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["departments"] }); + setName(""); + setParentId(""); + setError(""); + }, + onError: (err) => { + if (isApiError(err)) { + setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); + return; + } + setError(err instanceof Error ? err.message : "创建部门失败"); + }, + }); + + const archiveMutation = useMutation({ + mutationFn: (departmentId: string) => archiveDepartment(departmentId), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["departments"] }); + setError(""); + }, + onError: (err) => { + if (isApiError(err)) { + setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); + return; + } + setError(err instanceof Error ? err.message : "归档部门失败"); + }, + }); + + return ( +
+ + 返回用户管理 + + } + /> + + {error ? ( +

+ {error} +

+ ) : null} + + +
+

新建部门

+ +
+ +
+
+ + setName(event.target.value)} + /> +
+ +
+ + +
+ +
+ +
+
+
+ + +

部门列表

+ + {listQuery.isLoading ?

加载中...

: null} + + {listQuery.isError ? ( +

+ {isApiError(listQuery.error) + ? `${listQuery.error.code}: ${mapApiErrorMessage( + listQuery.error.code, + listQuery.error.details, + listQuery.error.message, + )}` + : "加载部门列表失败"} +

+ ) : null} + + {!listQuery.isLoading && !listQuery.isError ? ( +
+ + + + 名称 + 上级部门 + 归档时间 + 操作 + + + + {departments.length ? ( + departments.map((item) => { + const parentName = item.parentId ? departmentById.get(item.parentId)?.name ?? "-" : "-"; + return ( + + +
{item.name}
+
{item.id}
+
+ {parentName} + + {item.archivedAt ? item.archivedAt.slice(0, 19).replace("T", " ") : "-"} + + + {!item.archivedAt ? ( + + ) : ( + 已归档 + )} + +
+ ); + }) + ) : ( + + + 暂无部门 + + + )} +
+
+
+ ) : null} +
+
+ ); +} + diff --git a/client/src/pages/UserManagementPage.tsx b/client/src/pages/UserManagementPage.tsx index 2db69ee7..5e9aaa59 100644 --- a/client/src/pages/UserManagementPage.tsx +++ b/client/src/pages/UserManagementPage.tsx @@ -2,6 +2,8 @@ import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { mapApiErrorMessage } from "../api/errorMessages"; import { isApiError } from "../api/types"; +import { fetchDepartments } from "../api/departments"; +import { fetchEmployees } from "../api/employees"; import { createUser, disableUser, @@ -51,6 +53,8 @@ export function UserManagementPage() { const [phone, setPhone] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); + const [departmentId, setDepartmentId] = useState(""); + const [directManagerId, setDirectManagerId] = useState(""); const [createRoles, setCreateRoles] = useState>(() => new Set(["employee"])); const [roleDialogUser, setRoleDialogUser] = useState(null); @@ -61,6 +65,16 @@ export function UserManagementPage() { queryFn: () => fetchUsers({ page, pageSize: PAGE_SIZE, status: status || undefined, q: q || undefined }), }); + const departmentsQuery = useQuery({ + queryKey: ["departments", "active"], + queryFn: () => fetchDepartments({ includeArchived: false }), + }); + + const managersQuery = useQuery({ + queryKey: ["employees", "active"], + queryFn: () => fetchEmployees({ page: 1, pageSize: 200, status: "active" }), + }); + const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE)); const createPayload = useMemo(() => { @@ -70,9 +84,11 @@ export function UserManagementPage() { phone: phone.trim(), email: email.trim(), password: password, + departmentId: departmentId || undefined, + directManagerId: directManagerId ? directManagerId : null, roles: Array.from(createRoles), }; - }, [createRoles, email, employeeNo, name, password, phone]); + }, [createRoles, departmentId, directManagerId, email, employeeNo, name, password, phone]); const createMutation = useMutation({ mutationFn: () => createUser(createPayload), @@ -84,6 +100,8 @@ export function UserManagementPage() { setPhone(""); setEmail(""); setPassword(""); + setDepartmentId(""); + setDirectManagerId(""); setCreateRoles(new Set(["employee"])); setError(""); }, @@ -225,6 +243,46 @@ export function UserManagementPage() { /> +
+ + +
+ +
+ + +
+
@@ -500,4 +558,3 @@ export function UserManagementPage() {
); } - diff --git a/client/src/router/index.tsx b/client/src/router/index.tsx index 0d0b10cc..55e1cc71 100644 --- a/client/src/router/index.tsx +++ b/client/src/router/index.tsx @@ -4,6 +4,7 @@ import { AuthLayout } from "../layouts/AuthLayout"; import { AuditLogsPage } from "../pages/AuditLogsPage"; import { ChangePasswordPage } from "../pages/ChangePasswordPage"; import { DashboardPage } from "../pages/DashboardPage"; +import { DepartmentsPage } from "../pages/DepartmentsPage"; import { DelegationsPage } from "../pages/DelegationsPage"; import { ForbiddenPage } from "../pages/ForbiddenPage"; import { InvoicePoolPage } from "../pages/InvoicePoolPage"; @@ -128,6 +129,15 @@ export function AppRouter() { } /> + + + + } + /> + } />