R46 A1-A7:落地绩效考核模块首版

这个提交包含在:
2026-03-29 16:30:29 +08:00
父节点 48f395e18d
当前提交 659e3153a3
共修改 19 个文件,包含 2652 行新增和 15 行删除
+121 -9
查看文件
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { Link, useParams, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
completeEmployeeChecklistTask,
@@ -30,10 +30,12 @@ import {
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";
@@ -45,7 +47,7 @@ import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { downloadBlob } from "../lib/download";
type ProfileTab = "profile" | "contracts" | "trainingCertificates";
type ProfileTab = "profile" | "contracts" | "trainingCertificates" | "performance";
type ContractFormState = {
contractNo: string;
@@ -119,6 +121,13 @@ function buildEmptyCertificateForm(): CertificateFormState {
};
}
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;
}
@@ -142,8 +151,10 @@ function trainingTypeLabel(type: EmployeeTrainingType) {
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],
@@ -154,7 +165,7 @@ export function EmployeeProfilePage() {
const canEditBaseFields = canManageEmployeeProfile || canEditSelfProfile;
const canEditExtendedFields = canManageEmployeeProfile;
const canUploadAvatar = canManageEmployeeProfile || canEditSelfProfile;
const [activeTab, setActiveTab] = useState<ProfileTab>("profile");
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);
@@ -192,6 +203,22 @@ export function EmployeeProfilePage() {
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) {
@@ -210,11 +237,15 @@ export function EmployeeProfilePage() {
}, [profileQuery.data]);
useEffect(() => {
if (canManageEmployeeProfile || activeTab === "profile") {
setActiveTab(requestedTab);
}, [employeeId, requestedTab]);
useEffect(() => {
if (canManageEmployeeProfile || canEditSelfProfile || activeTab === "profile" || activeTab === "performance") {
return;
}
setActiveTab("profile");
}, [activeTab, canManageEmployeeProfile]);
updateActiveTab("profile");
}, [activeTab, canEditSelfProfile, canManageEmployeeProfile]);
useEffect(() => {
setAvatarFile(null);
@@ -227,6 +258,7 @@ export function EmployeeProfilePage() {
setTrainingForm(buildEmptyTrainingForm());
setEditingCertificateId(null);
setCertificateForm(buildEmptyCertificateForm());
setActiveTab(requestedTab);
}, [employeeId]);
const saveMutation = useMutation({
@@ -445,10 +477,76 @@ export function EmployeeProfilePage() {
onError: (error) => toast.error(errorMessage(error, editingCertificateId ? "更新证书记录失败" : "新增证书记录失败")),
});
if (profileQuery.isLoading) {
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>;
}
@@ -582,11 +680,12 @@ export function EmployeeProfilePage() {
}
/>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ProfileTab)}>
<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">
@@ -786,7 +885,14 @@ export function EmployeeProfilePage() {
</div>
</div>
</div>
<StatusBadge status={item.status} />
<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>
))
@@ -1308,6 +1414,12 @@ export function EmployeeProfilePage() {
</div>
</TabsContent>
) : null}
{showPerformanceTab ? (
<TabsContent value="performance" className="space-y-6">
{performanceContent}
</TabsContent>
) : null}
</Tabs>
</div>
);