import { useEffect, useState } from "react"; 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs"; import { Textarea } from "../components/ui/textarea"; import { toast } from "sonner"; 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)) { return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; } 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(); const queryClient = useQueryClient(); const profileQuery = useQuery({ enabled: Boolean(employeeId), queryKey: ["employee-profile", employeeId], queryFn: () => fetchEmployeeProfile(employeeId), }); 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: "", probationEndDate: "", lastWorkingDate: "", workLocation: "", emergencyContactName: "", emergencyContactPhone: "", notes: "", }); const contractsQuery = useQuery({ enabled: Boolean(employeeId) && canManageEmployeeProfile, queryKey: ["employee-contracts", employeeId], queryFn: () => fetchEmployeeContracts(employeeId), }); useEffect(() => { if (!profileQuery.data) { return; } setForm({ 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 ?? "", }); }, [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: () => patchEmployeeProfile(employeeId, { ...(canEditExtendedFields ? { ...(form.title.trim() ? { title: form.title.trim() } : {}), hireDate: form.hireDate || null, probationEndDate: form.probationEndDate || null, lastWorkingDate: form.lastWorkingDate || null, } : {}), ...(canEditBaseFields ? { workLocation: form.workLocation, emergencyContactName: form.emergencyContactName, emergencyContactPhone: form.emergencyContactPhone, notes: form.notes, } : {}), }), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] }); toast.success("档案已保存"); }, 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" ? completeEmployeeChecklistTask(employeeId, taskId) : skipEmployeeChecklistTask(employeeId, taskId), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] }); toast.success("清单已更新"); }, onError: (error) => toast.error(errorMessage(error, "更新清单失败")), }); const regularizeMutation = useMutation({ mutationFn: () => regularizeEmployee(employeeId), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] }); toast.success("已转正"); }, onError: (error) => toast.error(errorMessage(error, "转正失败")), }); const transferMutation = useMutation({ mutationFn: () => transferEmployee(employeeId), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] }); toast.success("已记录调岗事件"); }, onError: (error) => toast.error(errorMessage(error, "调岗失败")), }); const offboardingMutation = useMutation({ mutationFn: () => startEmployeeOffboarding(employeeId), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] }); toast.success("已发起离职"); }, onError: (error) => toast.error(errorMessage(error, "离职失败")), }); 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, 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 (
) : 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 ? (