1429 行
64 KiB
TypeScript
1429 行
64 KiB
TypeScript
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,
|
|
skipEmployeeChecklistTask,
|
|
startEmployeeOffboarding,
|
|
transferEmployee,
|
|
uploadEmployeeContractAttachment,
|
|
uploadEmployeeAvatar,
|
|
type EmployeeCertificate,
|
|
type EmployeeContract,
|
|
type EmployeeContractType,
|
|
type EmployeeTraining,
|
|
type EmployeeTrainingStatus,
|
|
type EmployeeTrainingType,
|
|
} 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 { PageHeader } from "../components/common/PageHeader";
|
|
import { EmployeePerformancePanel } from "../components/people/EmployeePerformancePanel";
|
|
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";
|
|
|
|
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") {
|
|
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;
|
|
}
|
|
|
|
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 canEditSelfProfile = me?.id === employeeId;
|
|
const canEditBaseFields = canManageEmployeeProfile || canEditSelfProfile;
|
|
const canEditExtendedFields = canManageEmployeeProfile;
|
|
const canUploadAvatar = canManageEmployeeProfile || canEditSelfProfile;
|
|
const [activeTab, setActiveTab] = useState<ProfileTab>(requestedTab);
|
|
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
|
const [avatarInputKey, setAvatarInputKey] = useState(0);
|
|
const [editingContractId, setEditingContractId] = useState<string | null>(null);
|
|
const [contractAttachmentFile, setContractAttachmentFile] = useState<File | null>(null);
|
|
const [contractAttachmentInputKey, setContractAttachmentInputKey] = useState(0);
|
|
const [downloadingContractId, setDownloadingContractId] = useState<string | null>(null);
|
|
const [contractForm, setContractForm] = useState<ContractFormState>(buildEmptyContractForm());
|
|
const [editingTrainingId, setEditingTrainingId] = useState<string | null>(null);
|
|
const [trainingForm, setTrainingForm] = useState<TrainingFormState>(buildEmptyTrainingForm());
|
|
const [editingCertificateId, setEditingCertificateId] = useState<string | null>(null);
|
|
const [certificateForm, setCertificateForm] = useState<CertificateFormState>(buildEmptyCertificateForm());
|
|
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 (canManageEmployeeProfile || canEditSelfProfile || activeTab === "profile" || activeTab === "performance") {
|
|
return;
|
|
}
|
|
updateActiveTab("profile");
|
|
}, [activeTab, canEditSelfProfile, canManageEmployeeProfile]);
|
|
|
|
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 () => {
|
|
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,
|
|
};
|
|
|
|
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 =
|
|
canManageEmployeeProfile || canEditSelfProfile || performanceOnlyMode || performanceQuery.data?.viewerRole === "manager";
|
|
|
|
const performanceContent =
|
|
performanceQuery.isLoading ? (
|
|
<div className="card-std p-6 text-sm text-zinc-500">加载绩效记录中...</div>
|
|
) : performanceQuery.isError || !performanceQuery.data ? (
|
|
<div className="card-std p-6 text-sm text-red-600">{errorMessage(performanceQuery.error, "加载绩效记录失败")}</div>
|
|
) : (
|
|
<EmployeePerformancePanel
|
|
employeeId={employeeId}
|
|
timeline={performanceQuery.data}
|
|
invalidateKeys={[
|
|
["employee-performance", employeeId],
|
|
["employee-stats"],
|
|
]}
|
|
/>
|
|
);
|
|
|
|
if (profileQuery.isLoading || (activeTab === "performance" && profilePermissionDenied && performanceQuery.isLoading)) {
|
|
return <div className="card-std p-6 text-sm text-zinc-500">加载员工档案中...</div>;
|
|
}
|
|
|
|
if (performanceOnlyMode && performanceQuery.data) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHeader
|
|
title={`${performanceQuery.data.employee.name} 绩效`}
|
|
subtitle={`${performanceQuery.data.employee.employeeNo} · ${performanceQuery.data.employee.title}`}
|
|
actions={
|
|
<Button asChild variant="secondary" type="button">
|
|
<Link to="/people">返回人员管理</Link>
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<div className="card-std p-6">
|
|
<div className="flex items-center gap-4">
|
|
<EmployeeAvatar
|
|
name={performanceQuery.data.employee.name}
|
|
src={performanceQuery.data.employee.avatarUrl}
|
|
size="xl"
|
|
/>
|
|
<div>
|
|
<div className="text-xl font-semibold text-zinc-900">{performanceQuery.data.employee.name}</div>
|
|
<div className="mt-1 text-sm text-zinc-500">
|
|
{performanceQuery.data.employee.employeeNo} · {performanceQuery.data.employee.department?.name ?? "-"}
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
<StatusBadge status={performanceQuery.data.employee.status} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Tabs value="performance" onValueChange={() => {}}>
|
|
<TabsList className="flex w-full flex-wrap justify-start gap-1">
|
|
<TabsTrigger value="performance">绩效</TabsTrigger>
|
|
</TabsList>
|
|
<TabsContent value="performance" className="space-y-6">
|
|
{performanceContent}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (profileQuery.isError || !profileQuery.data) {
|
|
return <div className="card-std p-6 text-sm text-red-600">{errorMessage(profileQuery.error, "加载员工档案失败")}</div>;
|
|
}
|
|
|
|
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 ?? [];
|
|
|
|
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() {
|
|
if (!window.confirm("确认删除当前头像?")) {
|
|
return;
|
|
}
|
|
deleteAvatarMutation.mutate();
|
|
}
|
|
|
|
function handleDeleteContract(contract: EmployeeContract) {
|
|
if (contract.status === "active") {
|
|
toast.error("生效中的合同需先终止后再删除");
|
|
return;
|
|
}
|
|
if (!window.confirm(`确认删除合同 ${contract.contractNo}?`)) {
|
|
return;
|
|
}
|
|
deleteContractMutation.mutate(contract.id);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHeader
|
|
title={`${profile.employee.name} 档案`}
|
|
subtitle={`${profile.employee.employeeNo} · ${profile.employee.title}`}
|
|
actions={
|
|
canManageEmployeeProfile ? (
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button type="button" variant="secondary" onClick={() => regularizeMutation.mutate()} disabled={regularizeMutation.isPending}>
|
|
转正
|
|
</Button>
|
|
<Button type="button" variant="secondary" onClick={() => transferMutation.mutate()} disabled={transferMutation.isPending}>
|
|
记录调岗
|
|
</Button>
|
|
<Button type="button" onClick={() => offboardingMutation.mutate()} disabled={offboardingMutation.isPending}>
|
|
发起离职
|
|
</Button>
|
|
</div>
|
|
) : null
|
|
}
|
|
/>
|
|
|
|
<Tabs value={activeTab} onValueChange={(value) => updateActiveTab(value as ProfileTab)}>
|
|
<TabsList className="flex w-full flex-wrap justify-start gap-1">
|
|
<TabsTrigger value="profile">档案</TabsTrigger>
|
|
{canManageEmployeeProfile ? <TabsTrigger value="contracts">合同</TabsTrigger> : null}
|
|
{canManageEmployeeProfile ? <TabsTrigger value="trainingCertificates">培训与证书</TabsTrigger> : null}
|
|
{showPerformanceTab ? <TabsTrigger value="performance">绩效</TabsTrigger> : null}
|
|
</TabsList>
|
|
|
|
<TabsContent value="profile" className="space-y-6">
|
|
<div className="grid gap-6 xl:grid-cols-3">
|
|
<div className="card-std space-y-6 p-6 xl:col-span-2">
|
|
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<EmployeeAvatar name={profile.employee.name} src={profile.employee.avatarUrl} size="xl" />
|
|
<div>
|
|
<div className="text-xl font-semibold text-zinc-900">{profile.employee.name}</div>
|
|
<div className="mt-1 text-sm text-zinc-500">
|
|
{profile.employee.employeeNo} · {profile.employee.email}
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
<StatusBadge status={profile.employee.status} />
|
|
<StatusBadge status={profile.employee.employmentStatus} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{canUploadAvatar ? (
|
|
<div className="w-full max-w-sm space-y-2">
|
|
<label className="text-sm text-zinc-500">头像上传</label>
|
|
<Input
|
|
key={avatarInputKey}
|
|
type="file"
|
|
accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp"
|
|
onChange={(event) => setAvatarFile(event.target.files?.[0] ?? null)}
|
|
/>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button type="button" variant="secondary" onClick={() => avatarMutation.mutate()} disabled={!avatarFile || avatarMutation.isPending}>
|
|
{avatarMutation.isPending ? "上传中..." : "上传头像"}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={handleDeleteAvatar}
|
|
disabled={!profile.employee.avatarUrl || deleteAvatarMutation.isPending}
|
|
>
|
|
{deleteAvatarMutation.isPending ? "删除中..." : "删除头像"}
|
|
</Button>
|
|
<div className="text-xs text-zinc-500">支持 jpg/png/webp,最大 2MB,自动裁剪为 200x200</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div>
|
|
<div className="text-sm text-zinc-500">部门 / 上级</div>
|
|
<div className="mt-2 text-sm text-zinc-900">
|
|
{profile.employee.department?.name ?? "-"} / {profile.employee.directManager?.name ?? "-"}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-zinc-500">手机 / 邮箱</div>
|
|
<div className="mt-2 text-sm text-zinc-900">
|
|
{profile.employee.phone} / {profile.employee.email}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-zinc-500">入职 / 试用期</div>
|
|
<div className="mt-2 text-sm text-zinc-900">
|
|
{profile.employee.hireDate ?? "-"} / {profile.employee.probationEndDate ?? "-"}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-zinc-500">离职日期</div>
|
|
<div className="mt-2 text-sm text-zinc-900">{profile.employee.lastWorkingDate ?? "-"}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{canEditExtendedFields ? (
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-zinc-500">职位</label>
|
|
<Input value={form.title} onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))} />
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-zinc-500">入职日期</label>
|
|
<Input type="date" value={form.hireDate} onChange={(event) => setForm((prev) => ({ ...prev, hireDate: event.target.value }))} />
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-zinc-500">试用期截止</label>
|
|
<Input type="date" value={form.probationEndDate} onChange={(event) => setForm((prev) => ({ ...prev, probationEndDate: event.target.value }))} />
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-zinc-500">离职日期</label>
|
|
<Input type="date" value={form.lastWorkingDate} onChange={(event) => setForm((prev) => ({ ...prev, lastWorkingDate: event.target.value }))} />
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{canEditBaseFields ? (
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-zinc-500">办公地点</label>
|
|
<Input value={form.workLocation} onChange={(event) => setForm((prev) => ({ ...prev, workLocation: event.target.value }))} />
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-zinc-500">紧急联系人</label>
|
|
<Input value={form.emergencyContactName} onChange={(event) => setForm((prev) => ({ ...prev, emergencyContactName: event.target.value }))} />
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-zinc-500">紧急联系电话</label>
|
|
<Input value={form.emergencyContactPhone} onChange={(event) => setForm((prev) => ({ ...prev, emergencyContactPhone: event.target.value }))} />
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div>
|
|
<div className="text-sm text-zinc-500">办公地点</div>
|
|
<div className="mt-2 text-sm text-zinc-900">{profile.employee.workLocation ?? "-"}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-zinc-500">紧急联系人</div>
|
|
<div className="mt-2 text-sm text-zinc-900">{profile.employee.emergencyContactName ?? "-"}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-zinc-500">紧急联系电话</div>
|
|
<div className="mt-2 text-sm text-zinc-900">{profile.employee.emergencyContactPhone ?? "-"}</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{canEditBaseFields ? (
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-zinc-500">备注</label>
|
|
<Textarea value={form.notes} onChange={(event) => setForm((prev) => ({ ...prev, notes: event.target.value }))} />
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<div className="text-sm text-zinc-500">备注</div>
|
|
<div className="mt-2 whitespace-pre-wrap text-sm text-zinc-900">{profile.employee.notes ?? "-"}</div>
|
|
</div>
|
|
)}
|
|
|
|
{canEditBaseFields ? (
|
|
<div className="flex justify-end">
|
|
<Button type="button" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
|
|
保存档案
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<div className="card-std space-y-4 p-6">
|
|
<h3 className="text-base font-semibold text-zinc-900">离职阻塞项</h3>
|
|
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
|
|
资产未归还:<span className="font-medium text-zinc-900">{profile.lifecycle.blockers.openAssetCount}</span>
|
|
</div>
|
|
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
|
|
待办审批任务:<span className="font-medium text-zinc-900">{profile.lifecycle.blockers.pendingWorkflowTaskCount}</span>
|
|
</div>
|
|
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
|
|
可完成离职:<span className="font-medium text-zinc-900">{profile.lifecycle.blockers.canCompleteOffboarding ? "是" : "否"}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card-std p-6">
|
|
<h3 className="text-base font-semibold text-zinc-900">汇报链</h3>
|
|
<div className="mt-4 space-y-3">
|
|
{reportingChain.map((item, index) => (
|
|
<div key={item.id} className="rounded-lg border border-zinc-200 p-3 text-sm">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<EmployeeAvatar name={item.name} src={item.avatarUrl} size="sm" />
|
|
<div>
|
|
<div className="font-medium text-zinc-900">{item.name}</div>
|
|
<div className="mt-1 text-zinc-500">
|
|
{item.employeeNo} · {item.title} · {item.department?.name ?? "-"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<StatusBadge status={item.status} />
|
|
</div>
|
|
{index === reportingChain.length - 1 ? <div className="mt-2 text-xs text-zinc-500">当前员工</div> : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card-std p-6">
|
|
<h3 className="text-base font-semibold text-zinc-900">直属下级</h3>
|
|
<div className="mt-4 space-y-3">
|
|
{directReports.length ? (
|
|
directReports.map((item) => (
|
|
<div key={item.id} className="rounded-lg border border-zinc-200 p-3 text-sm">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<EmployeeAvatar name={item.name} src={item.avatarUrl} size="sm" />
|
|
<div>
|
|
<div className="font-medium text-zinc-900">{item.name}</div>
|
|
<div className="mt-1 text-zinc-500">
|
|
{item.employeeNo} · {item.title} · {item.department?.name ?? "-"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{(canManageEmployeeProfile || canEditSelfProfile) && item.status === "active" ? (
|
|
<Button asChild type="button" size="sm" variant="secondary">
|
|
<Link to={`/people/${item.id}?tab=performance`}>绩效</Link>
|
|
</Button>
|
|
) : null}
|
|
<StatusBadge status={item.status} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="text-sm text-zinc-500">暂无直属下级</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-6 xl:grid-cols-2">
|
|
<div className="card-std p-6">
|
|
<h3 className="text-base font-semibold text-zinc-900">生命周期事件</h3>
|
|
<div className="mt-4 space-y-3">
|
|
{profile.lifecycle.events.length ? (
|
|
profile.lifecycle.events.map((item) => (
|
|
<div key={item.id} className="rounded-lg border border-zinc-200 p-3 text-sm">
|
|
<div className="font-medium text-zinc-900">{item.eventType}</div>
|
|
<div className="mt-1 text-zinc-500">生效日期:{item.effectiveDate ?? "-"}</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="text-sm text-zinc-500">暂无生命周期事件</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card-std p-6">
|
|
<h3 className="text-base font-semibold text-zinc-900">名下资产</h3>
|
|
<div className="mt-4 space-y-3">
|
|
{profile.assignedAssets.length ? (
|
|
profile.assignedAssets.map((item) => (
|
|
<div key={item.id} className="rounded-lg border border-zinc-200 p-3 text-sm">
|
|
<div className="font-medium text-zinc-900">
|
|
{item.assetNo} · {item.name}
|
|
</div>
|
|
<div className="mt-1 text-zinc-500">位置:{item.location ?? "-"} · 状态:{item.status}</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="text-sm text-zinc-500">当前无挂名资产</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card-std p-6">
|
|
<h3 className="text-base font-semibold text-zinc-900">Checklist</h3>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>场景</TableHead>
|
|
<TableHead>任务</TableHead>
|
|
<TableHead>状态</TableHead>
|
|
<TableHead>操作</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{profile.lifecycle.checklistTasks.map((item) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell>{item.scene}</TableCell>
|
|
<TableCell>{item.title}</TableCell>
|
|
<TableCell>
|
|
<StatusBadge status={item.status} />
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
type="button"
|
|
disabled={taskMutation.isPending || item.status !== "pending"}
|
|
onClick={() => taskMutation.mutate({ taskId: item.id, action: "complete" })}
|
|
>
|
|
完成
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
type="button"
|
|
disabled={taskMutation.isPending || item.status !== "pending"}
|
|
onClick={() => taskMutation.mutate({ taskId: item.id, action: "skip" })}
|
|
>
|
|
跳过
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{canManageEmployeeProfile ? (
|
|
<TabsContent value="contracts" className="space-y-6">
|
|
{contractsQuery.isError ? (
|
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
|
{errorMessage(contractsQuery.error, "加载合同失败")}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="grid gap-6 xl:grid-cols-[1.2fr,0.8fr]">
|
|
<div className="card-std p-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<h3 className="text-base font-semibold text-zinc-900">合同列表</h3>
|
|
<div className="mt-1 text-sm text-zinc-500">状态由终止日期与结束日期自动推导,附件支持上传/下载,active 合同需先终止后删除。</div>
|
|
</div>
|
|
<div className="text-sm text-zinc-500">{contracts.length} 份合同</div>
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>合同编号</TableHead>
|
|
<TableHead>类型</TableHead>
|
|
<TableHead>状态</TableHead>
|
|
<TableHead>日期</TableHead>
|
|
<TableHead>附件</TableHead>
|
|
<TableHead>操作</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{contracts.length ? (
|
|
contracts.map((contract) => (
|
|
<TableRow key={contract.id}>
|
|
<TableCell>{contract.contractNo}</TableCell>
|
|
<TableCell>{contractTypeLabel(contract.contractType)}</TableCell>
|
|
<TableCell>
|
|
<StatusBadge status={contract.status} />
|
|
</TableCell>
|
|
<TableCell>
|
|
<div>{contract.startDate ?? "-"} ~ {contract.endDate ?? "-"}</div>
|
|
<div className="text-xs text-zinc-500">终止:{contract.terminatedDate ?? "-"}</div>
|
|
</TableCell>
|
|
<TableCell className="max-w-[220px]">
|
|
{contract.attachmentUrl ? (
|
|
<div className="space-y-2">
|
|
<div className="truncate text-xs text-zinc-500">{contract.attachmentPath ?? "-"}</div>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="secondary"
|
|
onClick={() => handleDownloadContractAttachment(contract)}
|
|
disabled={downloadingContractId === contract.id}
|
|
>
|
|
{downloadingContractId === contract.id ? "下载中..." : "下载附件"}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="text-sm text-zinc-500">-</div>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button type="button" size="sm" variant="secondary" onClick={() => startEditingContract(contract)}>
|
|
编辑
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="secondary"
|
|
onClick={() => handleDeleteContract(contract)}
|
|
disabled={deleteContractMutation.isPending || contract.status === "active"}
|
|
>
|
|
删除
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
) : (
|
|
<TableRow>
|
|
<TableCell className="py-3 text-zinc-500" colSpan={6}>
|
|
{contractsQuery.isLoading ? "加载合同中..." : "暂无合同记录"}
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card-std space-y-4 p-6">
|
|
<div>
|
|
<h3 className="text-base font-semibold text-zinc-900">{editingContractId ? "编辑合同" : "新增合同"}</h3>
|
|
<div className="mt-1 text-sm text-zinc-500">新合同保存后再上传附件;附件仅支持 PDF/JPG/PNG,最大 10MB。</div>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-1">
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-zinc-500">合同编号</label>
|
|
<Input value={contractForm.contractNo} onChange={(event) => setContractForm((prev) => ({ ...prev, contractNo: event.target.value }))} />
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-zinc-500">合同类型</label>
|
|
<select
|
|
className="select-std"
|
|
value={contractForm.contractType}
|
|
onChange={(event) => setContractForm((prev) => ({ ...prev, contractType: event.target.value as EmployeeContractType }))}
|
|
>
|
|
{CONTRACT_TYPE_OPTIONS.map((item) => (
|
|
<option key={item.value} value={item.value}>
|
|
{item.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-zinc-500">开始日期</label>
|
|
<Input type="date" value={contractForm.startDate} onChange={(event) => setContractForm((prev) => ({ ...prev, startDate: event.target.value }))} />
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-zinc-500">结束日期</label>
|
|
<Input type="date" value={contractForm.endDate} onChange={(event) => setContractForm((prev) => ({ ...prev, endDate: event.target.value }))} />
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-zinc-500">终止日期</label>
|
|
<Input type="date" value={contractForm.terminatedDate} onChange={(event) => setContractForm((prev) => ({ ...prev, terminatedDate: event.target.value }))} />
|
|
</div>
|
|
</div>
|
|
|
|
{editingContractId ? (
|
|
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 p-4">
|
|
<div className="text-sm font-medium text-zinc-900">附件管理</div>
|
|
<div className="mt-1 text-xs text-zinc-500">
|
|
当前附件:{contracts.find((item) => item.id === editingContractId)?.attachmentPath ?? "未上传"}
|
|
</div>
|
|
<div className="mt-3 grid gap-3">
|
|
<Input
|
|
key={contractAttachmentInputKey}
|
|
type="file"
|
|
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
|
|
onChange={(event) => setContractAttachmentFile(event.target.files?.[0] ?? null)}
|
|
/>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => contractAttachmentMutation.mutate()}
|
|
disabled={!contractAttachmentFile || contractAttachmentMutation.isPending}
|
|
>
|
|
{contractAttachmentMutation.isPending ? "上传中..." : "上传/替换附件"}
|
|
</Button>
|
|
<div className="text-xs text-zinc-500">同扩展名覆盖,变更扩展名时自动清理旧文件。</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="rounded-2xl border border-dashed border-zinc-300 px-4 py-3 text-sm text-zinc-500">请先保存合同,再进行附件上传。</div>
|
|
)}
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
type="button"
|
|
onClick={() => contractMutation.mutate()}
|
|
disabled={contractMutation.isPending || !contractForm.contractNo.trim() || !contractForm.startDate || !contractForm.endDate}
|
|
>
|
|
{contractMutation.isPending ? "保存中..." : editingContractId ? "保存修改" : "新增合同"}
|
|
</Button>
|
|
{editingContractId ? (
|
|
<Button type="button" variant="secondary" onClick={resetContractEditor}>
|
|
取消编辑
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
) : null}
|
|
|
|
{canManageEmployeeProfile ? (
|
|
<TabsContent value="trainingCertificates" className="space-y-6">
|
|
{trainingsQuery.isError ? (
|
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
|
{errorMessage(trainingsQuery.error, "加载培训记录失败")}
|
|
</div>
|
|
) : null}
|
|
{certificatesQuery.isError ? (
|
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
|
{errorMessage(certificatesQuery.error, "加载证书记录失败")}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="grid gap-6 xl:grid-cols-[1.2fr,0.8fr]">
|
|
<div className="card-std p-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<h3 className="text-base font-semibold text-zinc-900">培训记录</h3>
|
|
<div className="mt-1 text-sm text-zinc-500">记录员工已计划、进行中和已完成培训。</div>
|
|
</div>
|
|
<div className="text-sm text-zinc-500">{trainings.length} 条记录</div>
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>名称</TableHead>
|
|
<TableHead>类型</TableHead>
|
|
<TableHead>状态</TableHead>
|
|
<TableHead>日期</TableHead>
|
|
<TableHead>备注</TableHead>
|
|
<TableHead>操作</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{trainings.length ? (
|
|
trainings.map((training) => (
|
|
<TableRow key={training.id}>
|
|
<TableCell>{training.name}</TableCell>
|
|
<TableCell>{trainingTypeLabel(training.trainingType)}</TableCell>
|
|
<TableCell>
|
|
<StatusBadge status={training.status} />
|
|
</TableCell>
|
|
<TableCell>
|
|
<div>{training.startDate ?? "-"} ~ {training.endDate ?? "-"}</div>
|
|
</TableCell>
|
|
<TableCell className="max-w-[220px] truncate">{training.notes ?? "-"}</TableCell>
|
|
<TableCell>
|
|
<Button type="button" size="sm" variant="secondary" onClick={() => startEditingTraining(training)}>
|
|
编辑
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
) : (
|
|
<TableRow>
|
|
<TableCell className="py-3 text-zinc-500" colSpan={6}>
|
|
{trainingsQuery.isLoading ? "加载培训记录中..." : "暂无培训记录"}
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card-std space-y-4 p-6">
|
|
<div>
|
|
<h3 className="text-base font-semibold text-zinc-900">{editingTrainingId ? "编辑培训记录" : "新增培训记录"}</h3>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-1">
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-zinc-500">培训名称</label>
|
|
<Input value={trainingForm.name} onChange={(event) => setTrainingForm((prev) => ({ ...prev, name: event.target.value }))} />
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-zinc-500">培训类型</label>
|
|
<select
|
|
className="select-std"
|
|
value={trainingForm.trainingType}
|
|
onChange={(event) => setTrainingForm((prev) => ({ ...prev, trainingType: event.target.value as EmployeeTrainingType }))}
|
|
>
|
|
{TRAINING_TYPE_OPTIONS.map((item) => (
|
|
<option key={item.value} value={item.value}>
|
|
{item.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-zinc-500">开始日期</label>
|
|
<Input type="date" value={trainingForm.startDate} onChange={(event) => setTrainingForm((prev) => ({ ...prev, startDate: event.target.value }))} />
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-zinc-500">结束日期</label>
|
|
<Input type="date" value={trainingForm.endDate} onChange={(event) => setTrainingForm((prev) => ({ ...prev, endDate: event.target.value }))} />
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-zinc-500">状态</label>
|
|
<select
|
|
className="select-std"
|
|
value={trainingForm.status}
|
|
onChange={(event) => setTrainingForm((prev) => ({ ...prev, status: event.target.value as EmployeeTrainingStatus }))}
|
|
>
|
|
{TRAINING_STATUS_OPTIONS.map((item) => (
|
|
<option key={item.value} value={item.value}>
|
|
{item.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-zinc-500">备注</label>
|
|
<Textarea value={trainingForm.notes} onChange={(event) => setTrainingForm((prev) => ({ ...prev, notes: event.target.value }))} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
type="button"
|
|
onClick={() => trainingMutation.mutate()}
|
|
disabled={trainingMutation.isPending || !trainingForm.name.trim() || !trainingForm.startDate}
|
|
>
|
|
{trainingMutation.isPending ? "保存中..." : editingTrainingId ? "保存修改" : "新增培训"}
|
|
</Button>
|
|
{editingTrainingId ? (
|
|
<Button type="button" variant="secondary" onClick={resetTrainingEditor}>
|
|
取消编辑
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-6 xl:grid-cols-[1.2fr,0.8fr]">
|
|
<div className="card-std p-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<h3 className="text-base font-semibold text-zinc-900">证书记录</h3>
|
|
<div className="mt-1 text-sm text-zinc-500">本轮只保存证书附件路径字符串,不做文件上传。</div>
|
|
</div>
|
|
<div className="text-sm text-zinc-500">{certificates.length} 条记录</div>
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>证书</TableHead>
|
|
<TableHead>机构</TableHead>
|
|
<TableHead>状态</TableHead>
|
|
<TableHead>日期</TableHead>
|
|
<TableHead>编号 / 附件</TableHead>
|
|
<TableHead>操作</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{certificates.length ? (
|
|
certificates.map((certificate) => (
|
|
<TableRow key={certificate.id}>
|
|
<TableCell>{certificate.name}</TableCell>
|
|
<TableCell>{certificate.issuer}</TableCell>
|
|
<TableCell>
|
|
<StatusBadge status={certificate.status} />
|
|
</TableCell>
|
|
<TableCell>
|
|
<div>取得:{certificate.obtainedDate ?? "-"}</div>
|
|
<div className="text-xs text-zinc-500">到期:{certificate.expiryDate ?? "-"}</div>
|
|
</TableCell>
|
|
<TableCell className="max-w-[220px]">
|
|
<div className="truncate">{certificate.certificateNo ?? "-"}</div>
|
|
<div className="truncate text-xs text-zinc-500">{certificate.attachmentPath ?? "-"}</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Button type="button" size="sm" variant="secondary" onClick={() => startEditingCertificate(certificate)}>
|
|
编辑
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
) : (
|
|
<TableRow>
|
|
<TableCell className="py-3 text-zinc-500" colSpan={6}>
|
|
{certificatesQuery.isLoading ? "加载证书记录中..." : "暂无证书记录"}
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card-std space-y-4 p-6">
|
|
<div>
|
|
<h3 className="text-base font-semibold text-zinc-900">{editingCertificateId ? "编辑证书记录" : "新增证书记录"}</h3>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-1">
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-zinc-500">证书名称</label>
|
|
<Input value={certificateForm.name} onChange={(event) => setCertificateForm((prev) => ({ ...prev, name: event.target.value }))} />
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-zinc-500">颁发机构</label>
|
|
<Input value={certificateForm.issuer} onChange={(event) => setCertificateForm((prev) => ({ ...prev, issuer: event.target.value }))} />
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-zinc-500">获取日期</label>
|
|
<Input type="date" value={certificateForm.obtainedDate} onChange={(event) => setCertificateForm((prev) => ({ ...prev, obtainedDate: event.target.value }))} />
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-zinc-500">到期日期</label>
|
|
<Input type="date" value={certificateForm.expiryDate} onChange={(event) => setCertificateForm((prev) => ({ ...prev, expiryDate: event.target.value }))} />
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-zinc-500">证书编号</label>
|
|
<Input value={certificateForm.certificateNo} onChange={(event) => setCertificateForm((prev) => ({ ...prev, certificateNo: event.target.value }))} />
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-zinc-500">附件路径</label>
|
|
<Input
|
|
value={certificateForm.attachmentPath}
|
|
onChange={(event) => setCertificateForm((prev) => ({ ...prev, attachmentPath: event.target.value }))}
|
|
placeholder="例如:certificates/pmp.pdf"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
type="button"
|
|
onClick={() => certificateMutation.mutate()}
|
|
disabled={certificateMutation.isPending || !certificateForm.name.trim() || !certificateForm.issuer.trim() || !certificateForm.obtainedDate}
|
|
>
|
|
{certificateMutation.isPending ? "保存中..." : editingCertificateId ? "保存修改" : "新增证书"}
|
|
</Button>
|
|
{editingCertificateId ? (
|
|
<Button type="button" variant="secondary" onClick={resetCertificateEditor}>
|
|
取消编辑
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
) : null}
|
|
|
|
{showPerformanceTab ? (
|
|
<TabsContent value="performance" className="space-y-6">
|
|
{performanceContent}
|
|
</TabsContent>
|
|
) : null}
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default EmployeeProfilePage;
|