From c3873999c26587d53113f3d0243613edda688e6b Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 29 Mar 2026 11:29:44 +0800 Subject: [PATCH] =?UTF-8?q?A1/A3/A5/A7/A8=20=E5=AE=8C=E6=88=90=E5=91=98?= =?UTF-8?q?=E5=B7=A5=E5=A4=B4=E5=83=8F=E5=89=8D=E7=AB=AF=E3=80=81=E7=BB=84?= =?UTF-8?q?=E7=BB=87=E6=9E=B6=E6=9E=84=E9=A1=B5=E4=B8=8EQA=E5=9B=9E?= =?UTF-8?q?=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/employees.ts | 107 ++- client/src/api/presence.ts | 1 + .../src/components/common/EmployeeAvatar.tsx | 72 ++ client/src/components/common/StatusBadge.tsx | 1 + client/src/components/navigation/NavLinks.tsx | 1 + client/src/features/navigation/schema.ts | 3 +- client/src/pages/EmployeeProfilePage.tsx | 784 +++++++++++++----- client/src/pages/OrganizationPage.tsx | 175 ++++ client/src/pages/PeoplePage.tsx | 12 +- client/src/pages/TeamPresencePage.tsx | 19 +- client/src/router/index.tsx | 10 + client/src/router/lazy/OrganizationPage.ts | 1 + client/src/router/lazy/index.ts | 1 + .../2026-03-29_r43-employee-enhancements.csv | 9 + server/src/services/workspaceSettings.js | 2 + 15 files changed, 971 insertions(+), 227 deletions(-) create mode 100644 client/src/components/common/EmployeeAvatar.tsx create mode 100644 client/src/pages/OrganizationPage.tsx create mode 100644 client/src/router/lazy/OrganizationPage.ts create mode 100644 issues/2026-03-29_r43-employee-enhancements.csv diff --git a/client/src/api/employees.ts b/client/src/api/employees.ts index b8c88d31..16528e27 100644 --- a/client/src/api/employees.ts +++ b/client/src/api/employees.ts @@ -1,6 +1,13 @@ import { apiRequestData } from "./http"; export type EmployeeStatus = "active" | "archived"; +export type EmployeeContractType = "labor" | "nda" | "non_compete" | "internship"; +export type EmployeeContractStatus = "active" | "expired" | "terminated"; + +export type EmployeeDepartmentSummary = { + id: string; + name: string; +}; export type EmployeeSummary = { id: string; @@ -12,6 +19,7 @@ export type EmployeeSummary = { departmentId: string; directManagerId: string | null; title: string; + avatarUrl: string | null; roleCodes: string[]; createdAt: string | null; updatedAt: string | null; @@ -61,7 +69,46 @@ export type EmployeeOrganizationNode = { name: string; title: string; status: EmployeeStatus; - department: { id: string; name: string } | null; + avatarUrl: string | null; + department: EmployeeDepartmentSummary | null; +}; + +export type EmployeeContract = { + id: string; + employeeId: string; + contractNo: string; + contractType: EmployeeContractType; + startDate: string | null; + endDate: string | null; + terminatedDate: string | null; + attachmentPath: string | null; + status: EmployeeContractStatus; + createdAt: string | null; + createdBy: string | null; + updatedAt: string | null; + updatedBy: string | null; +}; + +export type OrganizationTreeNode = { + id: string; + employeeNo: string; + name: string; + title: string; + status: EmployeeStatus; + avatarUrl: string | null; + department: EmployeeDepartmentSummary | null; + externalManager: { + id: string; + employeeNo: string; + name: string; + department: EmployeeDepartmentSummary | null; + } | null; + children: OrganizationTreeNode[]; +}; + +export type OrganizationDepartmentSection = { + department: EmployeeDepartmentSummary | null; + roots: OrganizationTreeNode[]; }; export type EmployeeProfile = { @@ -83,10 +130,11 @@ export type EmployeeProfile = { emergencyContactName: string | null; emergencyContactPhone: string | null; notes: string | null; + avatarUrl: string | null; archivedAt: string | null; roleCodes: string[]; - department: { id: string; name: string } | null; - directManager: { id: string; employeeNo: string; name: string } | null; + department: EmployeeDepartmentSummary | null; + directManager: { id: string; employeeNo: string; name: string; avatarUrl: string | null } | null; }; lifecycle: { events: EmployeeLifecycleEvent[]; @@ -131,6 +179,16 @@ export async function fetchEmployeeProfile(employeeId: string) { return apiRequestData(`/api/employees/${employeeId}/profile`); } +export async function uploadEmployeeAvatar(employeeId: string, file: File) { + const formData = new FormData(); + formData.append("file", file); + + return apiRequestData<{ employeeId: string; avatarUrl: string | null }>(`/api/employees/${employeeId}/avatar`, { + method: "PUT", + body: formData, + }); +} + export async function patchEmployeeProfile( employeeId: string, payload: Partial<{ @@ -202,6 +260,49 @@ export async function fetchEmployeeLifecycle(employeeId: string) { return apiRequestData(`/api/employees/${employeeId}/lifecycle`); } +export async function fetchEmployeeOrganization() { + return apiRequestData<{ items: OrganizationDepartmentSection[] }>("/api/employees/organization"); +} + +export async function fetchEmployeeContracts(employeeId: string) { + return apiRequestData<{ items: EmployeeContract[] }>(`/api/employees/${employeeId}/contracts`); +} + +export async function createEmployeeContract( + employeeId: string, + payload: { + contractNo: string; + contractType: EmployeeContractType; + startDate: string; + endDate: string; + terminatedDate?: string | null; + attachmentPath?: string | null; + }, +) { + return apiRequestData(`/api/employees/${employeeId}/contracts`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function patchEmployeeContract( + employeeId: string, + contractId: string, + payload: Partial<{ + contractNo: string; + contractType: EmployeeContractType; + startDate: string; + endDate: string; + terminatedDate: string | null; + attachmentPath: string | null; + }>, +) { + return apiRequestData(`/api/employees/${employeeId}/contracts/${contractId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + export async function completeEmployeeChecklistTask(employeeId: string, taskId: string) { return apiRequestData(`/api/employees/${employeeId}/checklist-tasks/${taskId}/complete`, { method: "POST", diff --git a/client/src/api/presence.ts b/client/src/api/presence.ts index 47163e14..3431214f 100644 --- a/client/src/api/presence.ts +++ b/client/src/api/presence.ts @@ -21,6 +21,7 @@ export type PresenceEmployee = { employeeNo: string; name: string; title: string; + avatarUrl: string | null; employmentStatus: string; department: { id: string; name: string } | null; }; diff --git a/client/src/components/common/EmployeeAvatar.tsx b/client/src/components/common/EmployeeAvatar.tsx new file mode 100644 index 00000000..e76874ef --- /dev/null +++ b/client/src/components/common/EmployeeAvatar.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from "react"; +import { cn } from "../../lib/utils"; + +type EmployeeAvatarSize = "sm" | "md" | "lg" | "xl"; + +const SIZE_CLASS_MAP: Record = { + sm: "size-9 text-xs", + md: "size-11 text-sm", + lg: "size-16 text-lg", + xl: "size-24 text-2xl", +}; + +function buildInitials(name: string) { + const trimmed = name.trim(); + if (!trimmed) { + return "员工"; + } + + if (/^[A-Za-z0-9 ]+$/.test(trimmed)) { + const segments = trimmed.split(/\s+/).filter(Boolean); + return segments.slice(0, 2).map((item) => item[0]?.toUpperCase() ?? "").join("") || trimmed.slice(0, 2).toUpperCase(); + } + + return trimmed.slice(0, 2); +} + +export function EmployeeAvatar({ + name, + src, + size = "md", + className, +}: { + name: string; + src?: string | null; + size?: EmployeeAvatarSize; + className?: string; +}) { + const [imageBroken, setImageBroken] = useState(false); + + useEffect(() => { + setImageBroken(false); + }, [src]); + + const showImage = Boolean(src) && !imageBroken; + + return ( + + ); +} + +export default EmployeeAvatar; diff --git a/client/src/components/common/StatusBadge.tsx b/client/src/components/common/StatusBadge.tsx index 53e07cb3..6b5fa2e4 100644 --- a/client/src/components/common/StatusBadge.tsx +++ b/client/src/components/common/StatusBadge.tsx @@ -29,6 +29,7 @@ const STATUS_MAP: Record = { skipped: { label: "已跳过", className: "badge-neutral" }, revoked: { label: "已撤销", className: "badge-neutral" }, expired: { label: "已过期", className: "badge-neutral" }, + terminated: { label: "已终止", className: "badge-error" }, scheduled: { label: "已排期", className: "badge-primary" }, }; diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index 07b19f3b..afec8cf7 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -29,6 +29,7 @@ export const NAV_ITEMS: NavItem[] = [ { to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_own", "project.view_all", "project.manage"] }, { to: "/system/workspace", label: "工作区设置", icon: "tune", anyOf: ["system.settings"] }, { to: "/people", label: "人员管理", icon: "group", anyOf: ["system.users", "report.dashboard.view"] }, + { to: "/organization", label: "组织架构", icon: "account_tree", anyOf: ["system.users", "report.dashboard.view"] }, { to: "/system/workflow-definitions", label: "流程定义", icon: "schema", anyOf: ["system.settings"] }, { to: "/system/settings", label: "系统设置", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.view", "masterdata.manage", "notification.channel.manage", "integration.manage"] }, { to: "/system/feature-flags", label: "功能开关", icon: "toggle_on", anyOf: ["system.settings"] }, diff --git a/client/src/features/navigation/schema.ts b/client/src/features/navigation/schema.ts index 3b834560..ad9c31e9 100644 --- a/client/src/features/navigation/schema.ts +++ b/client/src/features/navigation/schema.ts @@ -1,5 +1,5 @@ export const MAIN_NAV_ORDER = ["/portal", "/team", "/leave-requests", "/reimbursements", "/assets", "/weekly-reports", "/todos", "/announcements", "/projects"]; -export const ADMIN_NAV_ORDER = ["/system/workspace", "/people", "/system/workflow-definitions", "/system/settings", "/system/feature-flags", "/audit-logs"]; +export const ADMIN_NAV_ORDER = ["/system/workspace", "/people", "/organization", "/system/workflow-definitions", "/system/settings", "/system/feature-flags", "/audit-logs"]; export const BACKUP_NAV_ORDER = ["/workflow/tasks", "/contracts-registry", "/purchase-requests", "/travel-requests", "/budgets", "/payment-batches", "/ap-operations", "/invoice-pool", "/inventory-checks", "/system/cutover", "/system/integrations/erp", "/dashboard", "/settings/profile"]; const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [ @@ -19,6 +19,7 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [ { path: "/settings/profile", title: "个人设置", group: "账号设置" }, { path: "/system/workspace", title: "工作区设置", group: "管理入口" }, { path: "/people", title: "人员管理", group: "管理入口" }, + { path: "/organization", title: "组织架构", group: "管理入口" }, { path: "/system/workflow-definitions", title: "流程定义", group: "管理入口" }, { path: "/system/settings", title: "系统设置", group: "管理入口" }, { path: "/system/feature-flags", title: "功能开关", group: "管理入口" }, diff --git a/client/src/pages/EmployeeProfilePage.tsx b/client/src/pages/EmployeeProfilePage.tsx index e7a567d4..be2636cd 100644 --- a/client/src/pages/EmployeeProfilePage.tsx +++ b/client/src/pages/EmployeeProfilePage.tsx @@ -3,24 +3,50 @@ import { useParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { completeEmployeeChecklistTask, + createEmployeeContract, + fetchEmployeeContracts, fetchEmployeeProfile, + patchEmployeeContract, patchEmployeeProfile, regularizeEmployee, skipEmployeeChecklistTask, startEmployeeOffboarding, transferEmployee, + uploadEmployeeAvatar, + type EmployeeContract, + type EmployeeContractType, } from "../api/employees"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; +import { EmployeeAvatar } from "../components/common/EmployeeAvatar"; import { PageHeader } from "../components/common/PageHeader"; +import { StatusBadge } from "../components/common/StatusBadge"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; -import { Textarea } from "../components/ui/textarea"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; -import { StatusBadge } from "../components/common/StatusBadge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs"; +import { Textarea } from "../components/ui/textarea"; import { toast } from "sonner"; -import { isApiError } from "../api/types"; import { mapApiErrorMessage } from "../api/errorMessages"; +import { isApiError } from "../api/types"; + +type ProfileTab = "profile" | "contracts"; + +type ContractFormState = { + contractNo: string; + contractType: EmployeeContractType; + startDate: string; + endDate: string; + terminatedDate: string; + attachmentPath: string; +}; + +const CONTRACT_TYPE_OPTIONS: Array<{ value: EmployeeContractType; label: string }> = [ + { value: "labor", label: "劳动合同" }, + { value: "nda", label: "保密协议" }, + { value: "non_compete", label: "竞业限制" }, + { value: "internship", label: "实习协议" }, +]; function errorMessage(error: unknown, fallback: string) { if (isApiError(error)) { @@ -29,6 +55,21 @@ function errorMessage(error: unknown, fallback: string) { return error instanceof Error ? error.message : fallback; } +function buildEmptyContractForm(): ContractFormState { + return { + contractNo: "", + contractType: "labor", + startDate: "", + endDate: "", + terminatedDate: "", + attachmentPath: "", + }; +} + +function contractTypeLabel(type: EmployeeContractType) { + return CONTRACT_TYPE_OPTIONS.find((item) => item.value === type)?.label ?? type; +} + export function EmployeeProfilePage() { const { employeeId = "" } = useParams(); const { permissions, me } = useAuth(); @@ -38,12 +79,16 @@ export function EmployeeProfilePage() { queryKey: ["employee-profile", employeeId], queryFn: () => fetchEmployeeProfile(employeeId), }); - - const profile = profileQuery.data; const canManageEmployeeProfile = hasPermission(permissions, "system.users"); const canEditSelfProfile = me?.id === employeeId; const canEditBaseFields = canManageEmployeeProfile || canEditSelfProfile; const canEditExtendedFields = canManageEmployeeProfile; + const canUploadAvatar = canManageEmployeeProfile || canEditSelfProfile; + const [activeTab, setActiveTab] = useState("profile"); + const [avatarFile, setAvatarFile] = useState(null); + const [avatarInputKey, setAvatarInputKey] = useState(0); + const [editingContractId, setEditingContractId] = useState(null); + const [contractForm, setContractForm] = useState(buildEmptyContractForm()); const [form, setForm] = useState({ title: "", hireDate: "", @@ -55,19 +100,41 @@ export function EmployeeProfilePage() { notes: "", }); + const contractsQuery = useQuery({ + enabled: Boolean(employeeId) && canManageEmployeeProfile, + queryKey: ["employee-contracts", employeeId], + queryFn: () => fetchEmployeeContracts(employeeId), + }); + useEffect(() => { - if (!profile) return; + if (!profileQuery.data) { + return; + } setForm({ - title: profile.employee.title ?? "", - hireDate: profile.employee.hireDate ?? "", - probationEndDate: profile.employee.probationEndDate ?? "", - lastWorkingDate: profile.employee.lastWorkingDate ?? "", - workLocation: profile.employee.workLocation ?? "", - emergencyContactName: profile.employee.emergencyContactName ?? "", - emergencyContactPhone: profile.employee.emergencyContactPhone ?? "", - notes: profile.employee.notes ?? "", + title: profileQuery.data.employee.title ?? "", + hireDate: profileQuery.data.employee.hireDate ?? "", + probationEndDate: profileQuery.data.employee.probationEndDate ?? "", + lastWorkingDate: profileQuery.data.employee.lastWorkingDate ?? "", + workLocation: profileQuery.data.employee.workLocation ?? "", + emergencyContactName: profileQuery.data.employee.emergencyContactName ?? "", + emergencyContactPhone: profileQuery.data.employee.emergencyContactPhone ?? "", + notes: profileQuery.data.employee.notes ?? "", }); - }, [profile]); + }, [profileQuery.data]); + + useEffect(() => { + if (canManageEmployeeProfile || activeTab !== "contracts") { + return; + } + setActiveTab("profile"); + }, [activeTab, canManageEmployeeProfile]); + + useEffect(() => { + setAvatarFile(null); + setAvatarInputKey((current) => current + 1); + setEditingContractId(null); + setContractForm(buildEmptyContractForm()); + }, [employeeId]); const saveMutation = useMutation({ mutationFn: () => @@ -96,6 +163,27 @@ export function EmployeeProfilePage() { onError: (error) => toast.error(errorMessage(error, "保存失败")), }); + const avatarMutation = useMutation({ + mutationFn: async () => { + if (!avatarFile) { + throw new Error("请先选择头像文件"); + } + return uploadEmployeeAvatar(employeeId, avatarFile); + }, + onSuccess: async () => { + setAvatarFile(null); + setAvatarInputKey((current) => current + 1); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] }), + queryClient.invalidateQueries({ queryKey: ["employees"] }), + queryClient.invalidateQueries({ queryKey: ["presence"] }), + queryClient.invalidateQueries({ queryKey: ["employee-organization"] }), + ]); + toast.success("头像已更新"); + }, + onError: (error) => toast.error(errorMessage(error, "头像上传失败")), + }); + const taskMutation = useMutation({ mutationFn: ({ taskId, action }: { taskId: string; action: "complete" | "skip" }) => action === "complete" @@ -135,23 +223,72 @@ export function EmployeeProfilePage() { onError: (error) => toast.error(errorMessage(error, "离职失败")), }); - if (profileQuery.isLoading || !profile) { + const contractMutation = useMutation({ + mutationFn: async () => { + const payload = { + contractNo: contractForm.contractNo.trim(), + contractType: contractForm.contractType, + startDate: contractForm.startDate, + endDate: contractForm.endDate, + terminatedDate: contractForm.terminatedDate || null, + attachmentPath: contractForm.attachmentPath.trim() ? contractForm.attachmentPath.trim() : null, + }; + + if (editingContractId) { + return patchEmployeeContract(employeeId, editingContractId, payload); + } + return createEmployeeContract(employeeId, payload); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["employee-contracts", employeeId] }); + setEditingContractId(null); + setContractForm(buildEmptyContractForm()); + toast.success(editingContractId ? "合同已更新" : "合同已新增"); + }, + onError: (error) => toast.error(errorMessage(error, editingContractId ? "更新合同失败" : "新增合同失败")), + }); + + if (profileQuery.isLoading) { return
加载员工档案中...
; } + if (profileQuery.isError || !profileQuery.data) { + return
{errorMessage(profileQuery.error, "加载员工档案失败")}
; + } + + const profile = profileQuery.data; const reportingChain = profile.organization.reportingChain.length ? profile.organization.reportingChain : [ - { - id: profile.employee.id, - employeeNo: profile.employee.employeeNo, - name: profile.employee.name, - title: profile.employee.title, - status: profile.employee.status, - department: profile.employee.department, - }, - ]; + { + id: profile.employee.id, + employeeNo: profile.employee.employeeNo, + name: profile.employee.name, + title: profile.employee.title, + status: profile.employee.status, + avatarUrl: profile.employee.avatarUrl, + department: profile.employee.department, + }, + ]; const directReports = profile.organization.directReports; + const contracts = contractsQuery.data?.items ?? []; + + function resetContractEditor() { + setEditingContractId(null); + setContractForm(buildEmptyContractForm()); + } + + function startEditingContract(contract: EmployeeContract) { + setEditingContractId(contract.id); + setContractForm({ + contractNo: contract.contractNo, + contractType: contract.contractType, + startDate: contract.startDate ?? "", + endDate: contract.endDate ?? "", + terminatedDate: contract.terminatedDate ?? "", + attachmentPath: contract.attachmentPath ?? "", + }); + } return (
@@ -159,218 +296,433 @@ export function EmployeeProfilePage() { title={`${profile.employee.name} 档案`} subtitle={`${profile.employee.employeeNo} · ${profile.employee.title}`} actions={ -
- - - -
+ canManageEmployeeProfile ? ( +
+ + + +
+ ) : null } /> -
-
-
-
-
账号状态
-
+ setActiveTab(value as ProfileTab)}> + + 档案 + {canManageEmployeeProfile ? 合同 : null} + + + +
+
+
+
+ +
+
{profile.employee.name}
+
+ {profile.employee.employeeNo} · {profile.employee.email} +
+
+ + +
+
+
+ + {canUploadAvatar ? ( +
+ + setAvatarFile(event.target.files?.[0] ?? null)} + /> +
+ +
支持 jpg/png/webp,最大 2MB,自动裁剪为 200x200
+
+
+ ) : null} +
+ +
+
+
部门 / 上级
+
+ {profile.employee.department?.name ?? "-"} / {profile.employee.directManager?.name ?? "-"} +
+
+
+
手机 / 邮箱
+
+ {profile.employee.phone} / {profile.employee.email} +
+
+
+
入职 / 试用期
+
+ {profile.employee.hireDate ?? "-"} / {profile.employee.probationEndDate ?? "-"} +
+
+
+
离职日期
+
{profile.employee.lastWorkingDate ?? "-"}
+
+
+ + {canEditExtendedFields ? ( +
+
+ + setForm((prev) => ({ ...prev, title: event.target.value }))} /> +
+
+ + setForm((prev) => ({ ...prev, hireDate: event.target.value }))} /> +
+
+ + setForm((prev) => ({ ...prev, probationEndDate: event.target.value }))} /> +
+
+ + setForm((prev) => ({ ...prev, lastWorkingDate: event.target.value }))} /> +
+
+ ) : null} + + {canEditBaseFields ? ( +
+
+ + setForm((prev) => ({ ...prev, workLocation: event.target.value }))} /> +
+
+ + setForm((prev) => ({ ...prev, emergencyContactName: event.target.value }))} /> +
+
+ + setForm((prev) => ({ ...prev, emergencyContactPhone: event.target.value }))} /> +
+
+ ) : ( +
+
+
办公地点
+
{profile.employee.workLocation ?? "-"}
+
+
+
紧急联系人
+
{profile.employee.emergencyContactName ?? "-"}
+
+
+
紧急联系电话
+
{profile.employee.emergencyContactPhone ?? "-"}
+
+
+ )} + + {canEditBaseFields ? ( +
+ +