R46 A1-A7:落地绩效考核模块首版
这个提交包含在:
@@ -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>
|
||||
);
|
||||
|
||||
在新工单中引用
屏蔽一个用户