import { useEffect, useState } from "react"; import { Link, useParams, useSearchParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { completeEmployeeChecklistTask, createEmployeeCertificate, createEmployeeContract, createEmployeeTraining, deleteEmployeeAvatar, deleteEmployeeContract, downloadEmployeeContractAttachment, fetchEmployeeCertificates, fetchEmployeeContracts, fetchEmployeeProfile, fetchEmployeeTrainings, patchEmployeeCertificate, patchEmployeeContract, patchEmployeeProfile, patchEmployeeTraining, regularizeEmployee, retryEmployeeWecomDirectorySync, skipEmployeeChecklistTask, startEmployeeOffboarding, transferEmployee, uploadEmployeeContractAttachment, uploadEmployeeAvatar, type EmployeeCertificate, type EmployeeContract, type EmployeeContractType, type EmployeeTraining, type EmployeeTrainingStatus, type EmployeeTrainingType, type WecomDirectoryState, } from "../api/employees"; import { fetchEmployeePerformanceTimeline } from "../api/performance"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; import { EmployeeAvatar } from "../components/common/EmployeeAvatar"; import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog"; import { PageHeader } from "../components/common/PageHeader"; import { EmployeePerformancePanel } from "../components/people/EmployeePerformancePanel"; import { EmployeeSalaryPanel } from "../components/people/EmployeeSalaryPanel"; 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"; import { downloadBlob } from "../lib/download"; type ProfileTab = "profile" | "contracts" | "trainingCertificates" | "performance" | "salary"; type ContractFormState = { contractNo: string; contractType: EmployeeContractType; startDate: string; endDate: string; terminatedDate: string; }; type TrainingFormState = { name: string; trainingType: EmployeeTrainingType; startDate: string; endDate: string; status: EmployeeTrainingStatus; notes: string; }; type CertificateFormState = { name: string; issuer: string; obtainedDate: string; expiryDate: string; certificateNo: 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: "", }; } function buildEmptyTrainingForm(): TrainingFormState { return { name: "", trainingType: "internal", startDate: "", endDate: "", status: "planned", notes: "", }; } function buildEmptyCertificateForm(): CertificateFormState { return { name: "", issuer: "", obtainedDate: "", expiryDate: "", certificateNo: "", attachmentPath: "", }; } function parseProfileTab(value: string | null): ProfileTab { if (value === "contracts" || value === "trainingCertificates" || value === "performance" || value === "salary") { return value; } return "profile"; } function contractTypeLabel(type: EmployeeContractType) { return CONTRACT_TYPE_OPTIONS.find((item) => item.value === type)?.label ?? type; } const TRAINING_TYPE_OPTIONS: Array<{ value: EmployeeTrainingType; label: string }> = [ { value: "internal", label: "内部培训" }, { value: "external", label: "外部培训" }, { value: "online", label: "在线课程" }, ]; const TRAINING_STATUS_OPTIONS: Array<{ value: EmployeeTrainingStatus; label: string }> = [ { value: "planned", label: "已计划" }, { value: "in_progress", label: "进行中" }, { value: "completed", label: "已完成" }, { value: "cancelled", label: "已取消" }, ]; function trainingTypeLabel(type: EmployeeTrainingType) { return TRAINING_TYPE_OPTIONS.find((item) => item.value === type)?.label ?? type; } function wecomDirectoryStatusView(state?: WecomDirectoryState) { const errorCode = state?.latestSync?.errorCode ?? ""; if (!state) return { label: "未同步", status: "not_triggered", hint: "无记录" }; if (state.status === "synced" || state.status === "sync_success") { return { label: "同步成功", status: "succeeded", hint: state.externalUserId ?? "已绑定" }; } if (state.status === "bound") return { label: "已绑定", status: "bound", hint: state.externalUserId ?? "已绑定" }; if (state.status === "sync_failed") return { label: "同步失败", status: "failed", hint: state.latestSync?.errorCode ?? "待重试" }; if (state.status === "sync_skipped") { return { label: errorCode === "wecom_contact_config_missing" || errorCode === "wecom_contact_sync_disabled" ? "配置缺失" : "已跳过", status: "skipped", hint: state.latestSync?.errorCode ?? "未执行", }; } return { label: "未绑定", status: "not_triggered", hint: "待创建" }; } export function EmployeeProfilePage() { const { employeeId = "" } = useParams(); const [searchParams, setSearchParams] = useSearchParams(); const { permissions, me } = useAuth(); const queryClient = useQueryClient(); const requestedTab = parseProfileTab(searchParams.get("tab")); const profileQuery = useQuery({ enabled: Boolean(employeeId), queryKey: ["employee-profile", employeeId], queryFn: () => fetchEmployeeProfile(employeeId), }); const canManageEmployeeProfile = hasPermission(permissions, "system.users"); const canManageSensitiveHr = hasPermission(permissions, "system.settings"); const canEditSelfProfile = me?.id === employeeId; const canEditBaseFields = canManageEmployeeProfile || canEditSelfProfile; const canEditExtendedFields = canManageEmployeeProfile; const canUploadAvatar = canManageEmployeeProfile || canEditSelfProfile; const [activeTab, setActiveTab] = useState(requestedTab); const [avatarFile, setAvatarFile] = useState(null); const [avatarInputKey, setAvatarInputKey] = useState(0); const [editingContractId, setEditingContractId] = useState(null); const [contractAttachmentFile, setContractAttachmentFile] = useState(null); const [contractAttachmentInputKey, setContractAttachmentInputKey] = useState(0); const [downloadingContractId, setDownloadingContractId] = useState(null); const [contractForm, setContractForm] = useState(buildEmptyContractForm()); const [editingTrainingId, setEditingTrainingId] = useState(null); const [trainingForm, setTrainingForm] = useState(buildEmptyTrainingForm()); const [editingCertificateId, setEditingCertificateId] = useState(null); const [certificateForm, setCertificateForm] = useState(buildEmptyCertificateForm()); const [deleteAvatarConfirmOpen, setDeleteAvatarConfirmOpen] = useState(false); const [deleteContractTarget, setDeleteContractTarget] = useState(null); const [offboardingConfirmOpen, setOffboardingConfirmOpen] = useState(false); 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), }); const trainingsQuery = useQuery({ enabled: Boolean(employeeId) && canManageEmployeeProfile && activeTab === "trainingCertificates", queryKey: ["employee-trainings", employeeId], queryFn: () => fetchEmployeeTrainings(employeeId), }); const certificatesQuery = useQuery({ enabled: Boolean(employeeId) && canManageEmployeeProfile && activeTab === "trainingCertificates", queryKey: ["employee-certificates", employeeId], queryFn: () => fetchEmployeeCertificates(employeeId), }); const performanceQuery = useQuery({ enabled: Boolean(employeeId) && activeTab === "performance", queryKey: ["employee-performance", employeeId], queryFn: () => fetchEmployeePerformanceTimeline(employeeId), }); function updateActiveTab(nextTab: ProfileTab) { setActiveTab(nextTab); const nextSearchParams = new URLSearchParams(searchParams); if (nextTab === "profile") { nextSearchParams.delete("tab"); } else { nextSearchParams.set("tab", nextTab); } setSearchParams(nextSearchParams, { replace: true }); } 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(() => { setActiveTab(requestedTab); }, [employeeId, requestedTab]); useEffect(() => { if (activeTab === "salary" && !canManageSensitiveHr && !canEditSelfProfile) { updateActiveTab("profile"); return; } if (activeTab === "performance" && !canManageSensitiveHr && !canEditSelfProfile) { updateActiveTab("profile"); return; } if (canManageEmployeeProfile || canEditSelfProfile || activeTab === "profile") { return; } updateActiveTab("profile"); }, [activeTab, canEditSelfProfile, canManageEmployeeProfile, canManageSensitiveHr]); useEffect(() => { setAvatarFile(null); setAvatarInputKey((current) => current + 1); setEditingContractId(null); setContractForm(buildEmptyContractForm()); setContractAttachmentFile(null); setContractAttachmentInputKey((current) => current + 1); setEditingTrainingId(null); setTrainingForm(buildEmptyTrainingForm()); setEditingCertificateId(null); setCertificateForm(buildEmptyCertificateForm()); setActiveTab(requestedTab); }, [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 deleteAvatarMutation = useMutation({ mutationFn: () => deleteEmployeeAvatar(employeeId), 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 () => { setOffboardingConfirmOpen(false); await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] }); toast.success("已发起离职"); }, onError: (error) => toast.error(errorMessage(error, "离职失败")), }); const wecomDirectoryRetryMutation = useMutation({ mutationFn: () => retryEmployeeWecomDirectorySync(employeeId), onSuccess: async (result) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] }), queryClient.invalidateQueries({ queryKey: ["employees"] }), ]); const code = result.result?.code; if (result.result?.skipped) { toast.warning(code ? `企微同步已跳过:${code}` : "企微同步已跳过"); return; } if (result.result?.status === "failed") { toast.error(code ? `企微同步失败:${code}` : "企微同步失败"); return; } 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, }; 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 ? "更新合同失败" : "新增合同失败")), }); const contractAttachmentMutation = useMutation({ mutationFn: async () => { if (!editingContractId) { throw new Error("请先选择一个已保存的合同"); } if (!contractAttachmentFile) { throw new Error("请先选择附件文件"); } return uploadEmployeeContractAttachment(employeeId, editingContractId, contractAttachmentFile); }, onSuccess: async () => { setContractAttachmentFile(null); setContractAttachmentInputKey((current) => current + 1); await queryClient.invalidateQueries({ queryKey: ["employee-contracts", employeeId] }); toast.success("合同附件已更新"); }, onError: (error) => toast.error(errorMessage(error, "上传合同附件失败")), }); const deleteContractMutation = useMutation({ mutationFn: (contractId: string) => deleteEmployeeContract(employeeId, contractId), onSuccess: async (_result, contractId) => { await queryClient.invalidateQueries({ queryKey: ["employee-contracts", employeeId] }); if (editingContractId === contractId) { setEditingContractId(null); setContractForm(buildEmptyContractForm()); setContractAttachmentFile(null); setContractAttachmentInputKey((current) => current + 1); } toast.success("合同已删除"); }, onError: (error) => toast.error(errorMessage(error, "删除合同失败")), }); const trainingMutation = useMutation({ mutationFn: async () => { const payload = { name: trainingForm.name.trim(), trainingType: trainingForm.trainingType, startDate: trainingForm.startDate, endDate: trainingForm.endDate || null, status: trainingForm.status, notes: trainingForm.notes.trim() ? trainingForm.notes.trim() : null, }; if (editingTrainingId) { return patchEmployeeTraining(employeeId, editingTrainingId, payload); } return createEmployeeTraining(employeeId, payload); }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["employee-trainings", employeeId] }); setEditingTrainingId(null); setTrainingForm(buildEmptyTrainingForm()); toast.success(editingTrainingId ? "培训记录已更新" : "培训记录已新增"); }, onError: (error) => toast.error(errorMessage(error, editingTrainingId ? "更新培训记录失败" : "新增培训记录失败")), }); const certificateMutation = useMutation({ mutationFn: async () => { const payload = { name: certificateForm.name.trim(), issuer: certificateForm.issuer.trim(), obtainedDate: certificateForm.obtainedDate, expiryDate: certificateForm.expiryDate || null, certificateNo: certificateForm.certificateNo.trim() ? certificateForm.certificateNo.trim() : null, attachmentPath: certificateForm.attachmentPath.trim() ? certificateForm.attachmentPath.trim() : null, }; if (editingCertificateId) { return patchEmployeeCertificate(employeeId, editingCertificateId, payload); } return createEmployeeCertificate(employeeId, payload); }, onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["employee-certificates", employeeId] }), queryClient.invalidateQueries({ queryKey: ["employee-stats"] }), ]); setEditingCertificateId(null); setCertificateForm(buildEmptyCertificateForm()); toast.success(editingCertificateId ? "证书记录已更新" : "证书记录已新增"); }, onError: (error) => toast.error(errorMessage(error, editingCertificateId ? "更新证书记录失败" : "新增证书记录失败")), }); const profilePermissionDenied = profileQuery.isError && isApiError(profileQuery.error) && profileQuery.error.status === 403; const performanceOnlyMode = activeTab === "performance" && profilePermissionDenied && Boolean(performanceQuery.data); const showPerformanceTab = canManageSensitiveHr || canEditSelfProfile || performanceOnlyMode || performanceQuery.data?.viewerRole === "manager"; const showSalaryTab = canManageSensitiveHr || canEditSelfProfile; const performanceContent = performanceQuery.isLoading ? (
加载绩效记录中...
) : performanceQuery.isError || !performanceQuery.data ? (
{errorMessage(performanceQuery.error, "加载绩效记录失败")}
) : ( ); const salaryContent = ; if (profileQuery.isLoading || (activeTab === "performance" && profilePermissionDenied && performanceQuery.isLoading)) { return
加载员工档案中...
; } if (performanceOnlyMode && performanceQuery.data) { return (
返回人员管理 } />
{performanceQuery.data.employee.name}
{performanceQuery.data.employee.employeeNo} · {performanceQuery.data.employee.department?.name ?? "-"}
{}}> 绩效 {performanceContent}
); } 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 ?? []; const trainings = trainingsQuery.data?.items ?? []; const certificates = certificatesQuery.data?.items ?? []; const wecomDirectoryView = wecomDirectoryStatusView(profile.employee.wecomDirectory); function resetContractEditor() { setEditingContractId(null); setContractForm(buildEmptyContractForm()); setContractAttachmentFile(null); setContractAttachmentInputKey((current) => current + 1); } function startEditingContract(contract: EmployeeContract) { setEditingContractId(contract.id); setContractForm({ contractNo: contract.contractNo, contractType: contract.contractType, startDate: contract.startDate ?? "", endDate: contract.endDate ?? "", terminatedDate: contract.terminatedDate ?? "", }); setContractAttachmentFile(null); setContractAttachmentInputKey((current) => current + 1); } function resetTrainingEditor() { setEditingTrainingId(null); setTrainingForm(buildEmptyTrainingForm()); } function startEditingTraining(training: EmployeeTraining) { setEditingTrainingId(training.id); setTrainingForm({ name: training.name, trainingType: training.trainingType, startDate: training.startDate ?? "", endDate: training.endDate ?? "", status: training.status, notes: training.notes ?? "", }); } function resetCertificateEditor() { setEditingCertificateId(null); setCertificateForm(buildEmptyCertificateForm()); } function startEditingCertificate(certificate: EmployeeCertificate) { setEditingCertificateId(certificate.id); setCertificateForm({ name: certificate.name, issuer: certificate.issuer, obtainedDate: certificate.obtainedDate ?? "", expiryDate: certificate.expiryDate ?? "", certificateNo: certificate.certificateNo ?? "", attachmentPath: certificate.attachmentPath ?? "", }); } async function handleDownloadContractAttachment(contract: EmployeeContract) { if (!contract.attachmentUrl) { return; } try { setDownloadingContractId(contract.id); const response = await downloadEmployeeContractAttachment(contract.attachmentUrl); downloadBlob(response.blob, response.filename ?? `${contract.contractNo}.bin`); } catch (error) { toast.error(errorMessage(error, "下载合同附件失败")); } finally { setDownloadingContractId(null); } } function handleDeleteAvatar() { setDeleteAvatarConfirmOpen(true); } function handleDeleteContract(contract: EmployeeContract) { if (contract.status === "active") { toast.error("生效中的合同需先终止后再删除"); return; } setDeleteContractTarget(contract); } return ( <>
) : null } /> updateActiveTab(value as ProfileTab)}> 档案 {canManageEmployeeProfile ? 合同 : null} {canManageEmployeeProfile ? 培训与证书 : null} {showPerformanceTab ? 绩效 : null} {showSalaryTab ? 薪资 : 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 ?? "-"}
企业微信通讯录
{wecomDirectoryView.hint}
{canManageEmployeeProfile ? ( ) : null}
{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 ? (