diff --git a/client/src/api/employees.ts b/client/src/api/employees.ts index 27b0765d..84586b70 100644 --- a/client/src/api/employees.ts +++ b/client/src/api/employees.ts @@ -1,9 +1,12 @@ -import { apiRequestData } from "./http"; +import { apiRequestBlob, apiRequestData, type ApiBlobResponse } from "./http"; export type EmployeeStatus = "active" | "archived"; export type EmployeeEmploymentStatus = "onboarding" | "probation" | "active" | "offboarding" | "inactive"; export type EmployeeContractType = "labor" | "nda" | "non_compete" | "internship"; export type EmployeeContractStatus = "active" | "expired" | "terminated"; +export type EmployeeTrainingType = "internal" | "external" | "online"; +export type EmployeeTrainingStatus = "planned" | "in_progress" | "completed" | "cancelled"; +export type EmployeeCertificateStatus = "valid" | "expired"; export type EmployeeDepartmentSummary = { id: string; @@ -87,6 +90,7 @@ export type EmployeeContract = { endDate: string | null; terminatedDate: string | null; attachmentPath: string | null; + attachmentUrl: string | null; status: EmployeeContractStatus; createdAt: string | null; createdBy: string | null; @@ -94,6 +98,37 @@ export type EmployeeContract = { updatedBy: string | null; }; +export type EmployeeTraining = { + id: string; + employeeId: string; + name: string; + trainingType: EmployeeTrainingType; + startDate: string | null; + endDate: string | null; + status: EmployeeTrainingStatus; + notes: string | null; + createdAt: string | null; + createdBy: string | null; + updatedAt: string | null; + updatedBy: string | null; +}; + +export type EmployeeCertificate = { + id: string; + employeeId: string; + name: string; + issuer: string; + obtainedDate: string | null; + expiryDate: string | null; + certificateNo: string | null; + attachmentPath: string | null; + status: EmployeeCertificateStatus; + createdAt: string | null; + createdBy: string | null; + updatedAt: string | null; + updatedBy: string | null; +}; + export type OrganizationTreeNode = { id: string; employeeNo: string; @@ -209,6 +244,12 @@ export type EmployeeStatsResponse = { dateTo: string; count: number; }; + certificateSummary: { + dateFrom: string; + dateTo: string; + validHolderCount: number; + expiringSoonCount: number; + }; }; export type BatchTransferDepartmentPayload = { @@ -255,6 +296,12 @@ export async function uploadEmployeeAvatar(employeeId: string, file: File) { }); } +export async function deleteEmployeeAvatar(employeeId: string) { + return apiRequestData<{ employeeId: string; avatarUrl: string | null }>(`/api/employees/${employeeId}/avatar`, { + method: "DELETE", + }); +} + export async function patchEmployeeProfile( employeeId: string, payload: Partial<{ @@ -373,6 +420,104 @@ export async function patchEmployeeContract( }); } +export async function uploadEmployeeContractAttachment(employeeId: string, contractId: string, file: File) { + const formData = new FormData(); + formData.append("file", file); + + return apiRequestData(`/api/employees/${employeeId}/contracts/${contractId}/attachment`, { + method: "PUT", + body: formData, + }); +} + +export async function downloadEmployeeContractAttachment(attachmentUrl: string): Promise { + return apiRequestBlob(attachmentUrl); +} + +export async function deleteEmployeeContract(employeeId: string, contractId: string) { + return apiRequestData<{ contractId: string; deleted: boolean }>(`/api/employees/${employeeId}/contracts/${contractId}`, { + method: "DELETE", + }); +} + +export async function fetchEmployeeTrainings(employeeId: string) { + return apiRequestData<{ items: EmployeeTraining[] }>(`/api/employees/${employeeId}/trainings`); +} + +export async function createEmployeeTraining( + employeeId: string, + payload: { + name: string; + trainingType: EmployeeTrainingType; + startDate: string; + endDate?: string | null; + status: EmployeeTrainingStatus; + notes?: string | null; + }, +) { + return apiRequestData(`/api/employees/${employeeId}/trainings`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function patchEmployeeTraining( + employeeId: string, + trainingId: string, + payload: Partial<{ + name: string; + trainingType: EmployeeTrainingType; + startDate: string; + endDate: string | null; + status: EmployeeTrainingStatus; + notes: string | null; + }>, +) { + return apiRequestData(`/api/employees/${employeeId}/trainings/${trainingId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + +export async function fetchEmployeeCertificates(employeeId: string) { + return apiRequestData<{ items: EmployeeCertificate[] }>(`/api/employees/${employeeId}/certificates`); +} + +export async function createEmployeeCertificate( + employeeId: string, + payload: { + name: string; + issuer: string; + obtainedDate: string; + expiryDate?: string | null; + certificateNo?: string | null; + attachmentPath?: string | null; + }, +) { + return apiRequestData(`/api/employees/${employeeId}/certificates`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function patchEmployeeCertificate( + employeeId: string, + certificateId: string, + payload: Partial<{ + name: string; + issuer: string; + obtainedDate: string; + expiryDate: string | null; + certificateNo: string | null; + attachmentPath: string | null; + }>, +) { + return apiRequestData(`/api/employees/${employeeId}/certificates/${certificateId}`, { + 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/components/common/StatusBadge.tsx b/client/src/components/common/StatusBadge.tsx index 6b5fa2e4..63ac7ddb 100644 --- a/client/src/components/common/StatusBadge.tsx +++ b/client/src/components/common/StatusBadge.tsx @@ -14,8 +14,11 @@ const STATUS_MAP: Record = { pending_ocr: { label: "识别中", className: "badge-warning" }, approved: { label: "已通过", className: "badge-success" }, completed: { label: "已完成", className: "badge-success" }, + in_progress: { label: "进行中", className: "badge-warning" }, available: { label: "可用", className: "badge-success" }, active: { label: "生效中", className: "badge-success" }, + valid: { label: "有效", className: "badge-success" }, + planned: { label: "已计划", className: "badge-primary" }, in_stock: { label: "库存中", className: "badge-primary" }, in_use: { label: "使用中", className: "badge-success" }, under_repair: { label: "维修中", className: "badge-warning" }, diff --git a/client/src/pages/EmployeeProfilePage.tsx b/client/src/pages/EmployeeProfilePage.tsx index be2636cd..10fb3cba 100644 --- a/client/src/pages/EmployeeProfilePage.tsx +++ b/client/src/pages/EmployeeProfilePage.tsx @@ -3,18 +3,32 @@ import { useParams } 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 { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; @@ -29,8 +43,9 @@ 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"; +type ProfileTab = "profile" | "contracts" | "trainingCertificates"; type ContractFormState = { contractNo: string; @@ -38,6 +53,23 @@ type ContractFormState = { 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; }; @@ -62,6 +94,27 @@ function buildEmptyContractForm(): ContractFormState { 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: "", }; } @@ -70,6 +123,23 @@ 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 { permissions, me } = useAuth(); @@ -88,7 +158,14 @@ export function EmployeeProfilePage() { 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 [form, setForm] = useState({ title: "", hireDate: "", @@ -105,6 +182,16 @@ export function EmployeeProfilePage() { 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), + }); useEffect(() => { if (!profileQuery.data) { @@ -123,7 +210,7 @@ export function EmployeeProfilePage() { }, [profileQuery.data]); useEffect(() => { - if (canManageEmployeeProfile || activeTab !== "contracts") { + if (canManageEmployeeProfile || activeTab === "profile") { return; } setActiveTab("profile"); @@ -134,6 +221,12 @@ export function EmployeeProfilePage() { setAvatarInputKey((current) => current + 1); setEditingContractId(null); setContractForm(buildEmptyContractForm()); + setContractAttachmentFile(null); + setContractAttachmentInputKey((current) => current + 1); + setEditingTrainingId(null); + setTrainingForm(buildEmptyTrainingForm()); + setEditingCertificateId(null); + setCertificateForm(buildEmptyCertificateForm()); }, [employeeId]); const saveMutation = useMutation({ @@ -184,6 +277,22 @@ export function EmployeeProfilePage() { 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" @@ -231,7 +340,6 @@ export function EmployeeProfilePage() { startDate: contractForm.startDate, endDate: contractForm.endDate, terminatedDate: contractForm.terminatedDate || null, - attachmentPath: contractForm.attachmentPath.trim() ? contractForm.attachmentPath.trim() : null, }; if (editingContractId) { @@ -248,6 +356,95 @@ export function EmployeeProfilePage() { 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 ? "更新证书记录失败" : "新增证书记录失败")), + }); + if (profileQuery.isLoading) { return
加载员工档案中...
; } @@ -272,10 +469,14 @@ export function EmployeeProfilePage() { ]; 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) { @@ -286,8 +487,77 @@ export function EmployeeProfilePage() { startDate: contract.startDate ?? "", endDate: contract.endDate ?? "", terminatedDate: contract.terminatedDate ?? "", - attachmentPath: contract.attachmentPath ?? "", }); + 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 ( @@ -316,6 +586,7 @@ export function EmployeeProfilePage() { 档案 {canManageEmployeeProfile ? 合同 : null} + {canManageEmployeeProfile ? 培训与证书 : null} @@ -349,6 +620,14 @@ export function EmployeeProfilePage() { +
支持 jpg/png/webp,最大 2MB,自动裁剪为 200x200
@@ -615,7 +894,7 @@ export function EmployeeProfilePage() {

合同列表

-
状态由终止日期与结束日期自动推导,本轮仅支持新增与编辑。
+
状态由终止日期与结束日期自动推导,附件支持上传/下载,active 合同需先终止后删除。
{contracts.length} 份合同
@@ -628,7 +907,7 @@ export function EmployeeProfilePage() { 类型 状态 日期 - 附件路径 + 附件 操作 @@ -645,11 +924,39 @@ export function EmployeeProfilePage() {
{contract.startDate ?? "-"} ~ {contract.endDate ?? "-"}
终止:{contract.terminatedDate ?? "-"}
- {contract.attachmentPath ?? "-"} + + {contract.attachmentUrl ? ( +
+
{contract.attachmentPath ?? "-"}
+ +
+ ) : ( +
-
+ )} +
- +
+ + +
)) @@ -668,7 +975,7 @@ export function EmployeeProfilePage() {

{editingContractId ? "编辑合同" : "新增合同"}

-
附件本轮只保存路径字符串,不做上传。
+
新合同保存后再上传附件;附件仅支持 PDF/JPG/PNG,最大 10MB。
@@ -702,14 +1009,44 @@ export function EmployeeProfilePage() { setContractForm((prev) => ({ ...prev, terminatedDate: event.target.value }))} />
-
- - setContractForm((prev) => ({ ...prev, attachmentPath: event.target.value }))} placeholder="例如:contracts/labor/E1001.pdf" /> -
+ {editingContractId ? ( +
+
附件管理
+
+ 当前附件:{contracts.find((item) => item.id === editingContractId)?.attachmentPath ?? "未上传"} +
+
+ setContractAttachmentFile(event.target.files?.[0] ?? null)} + /> +
+ +
同扩展名覆盖,变更扩展名时自动清理旧文件。
+
+
+
+ ) : ( +
请先保存合同,再进行附件上传。
+ )} +
- {editingContractId ? ( @@ -722,6 +1059,255 @@ export function EmployeeProfilePage() {
) : null} + + {canManageEmployeeProfile ? ( + + {trainingsQuery.isError ? ( +
+ {errorMessage(trainingsQuery.error, "加载培训记录失败")} +
+ ) : null} + {certificatesQuery.isError ? ( +
+ {errorMessage(certificatesQuery.error, "加载证书记录失败")} +
+ ) : null} + +
+
+
+
+

培训记录

+
记录员工已计划、进行中和已完成培训。
+
+
{trainings.length} 条记录
+
+ +
+ + + + 名称 + 类型 + 状态 + 日期 + 备注 + 操作 + + + + {trainings.length ? ( + trainings.map((training) => ( + + {training.name} + {trainingTypeLabel(training.trainingType)} + + + + +
{training.startDate ?? "-"} ~ {training.endDate ?? "-"}
+
+ {training.notes ?? "-"} + + + +
+ )) + ) : ( + + + {trainingsQuery.isLoading ? "加载培训记录中..." : "暂无培训记录"} + + + )} +
+
+
+
+ +
+
+

{editingTrainingId ? "编辑培训记录" : "新增培训记录"}

+
+ +
+
+ + setTrainingForm((prev) => ({ ...prev, name: event.target.value }))} /> +
+
+ + +
+
+ + setTrainingForm((prev) => ({ ...prev, startDate: event.target.value }))} /> +
+
+ + setTrainingForm((prev) => ({ ...prev, endDate: event.target.value }))} /> +
+
+ + +
+
+ +