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>
|
||||
);
|
||||
|
||||
@@ -292,6 +292,11 @@ export function PeoplePage() {
|
||||
subtitle="按当前员工档案与账号状态做筛选、批量处理和统计查看。"
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canManageUsers ? (
|
||||
<Button asChild variant="secondary" type="button">
|
||||
<Link to="/performance">绩效考核</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{canViewStats ? (
|
||||
<Button asChild variant="secondary" type="button">
|
||||
<Link to="/people/stats">员工统计</Link>
|
||||
|
||||
@@ -83,6 +83,22 @@ export function PeopleStatsPage() {
|
||||
{stats.certificateSummary.dateFrom} 至 {stats.certificateSummary.dateTo}
|
||||
</div>
|
||||
</section>
|
||||
<section className="card-std p-5">
|
||||
<div className="text-sm text-zinc-500">当前 / 最近周期平均分</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-zinc-900">
|
||||
{stats.performanceSummary.averageScore == null ? "-" : stats.performanceSummary.averageScore.toFixed(1)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-zinc-500">
|
||||
{stats.performanceSummary.cycle
|
||||
? `${stats.performanceSummary.cycle.name} · ${stats.performanceSummary.cycle.startDate} 至 ${stats.performanceSummary.cycle.endDate}`
|
||||
: "暂无可展示的绩效周期"}
|
||||
</div>
|
||||
</section>
|
||||
<section className="card-std p-5">
|
||||
<div className="text-sm text-zinc-500">当前 / 最近周期已评分人数</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-zinc-900">{stats.performanceSummary.reviewedCount}</div>
|
||||
<div className="mt-2 text-xs text-zinc-500">只统计当前 active 或最近 completed 周期</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
@@ -126,6 +142,25 @@ export function PeopleStatsPage() {
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="card-std p-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-zinc-900">绩效评分分布</h2>
|
||||
<p className="mt-1 text-sm text-zinc-500">按当前 active 周期或最近 completed 周期的总分分布汇总。</p>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-3 md:grid-cols-3 xl:grid-cols-5">
|
||||
{stats.performanceSummary.cycle && stats.performanceSummary.distribution.length ? (
|
||||
stats.performanceSummary.distribution.map((item) => (
|
||||
<div key={item.score} className="rounded-2xl border border-zinc-200 bg-white px-4 py-3">
|
||||
<div className="text-sm text-zinc-500">{item.score.toFixed(1)} 分</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-zinc-900">{item.count}</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-zinc-500">暂无绩效周期统计。</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
createPerformanceCycle,
|
||||
fetchPerformanceCycleOverview,
|
||||
fetchPerformanceCycles,
|
||||
patchPerformanceCycle,
|
||||
type PerformanceReviewCycleType,
|
||||
} from "../api/performance";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { isApiError } from "../api/types";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../components/ui/dialog";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type CycleFormState = {
|
||||
name: string;
|
||||
cycleType: PerformanceReviewCycleType;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
|
||||
const CYCLE_TYPE_OPTIONS: Array<{ value: PerformanceReviewCycleType; label: string }> = [
|
||||
{ value: "monthly", label: "月度" },
|
||||
{ value: "quarterly", label: "季度" },
|
||||
{ value: "semi_annual", label: "半年度" },
|
||||
{ value: "annual", label: "年度" },
|
||||
];
|
||||
|
||||
function buildEmptyCycleForm(): CycleFormState {
|
||||
return {
|
||||
name: "",
|
||||
cycleType: "quarterly",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
};
|
||||
}
|
||||
|
||||
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 cycleTypeLabel(value: PerformanceReviewCycleType) {
|
||||
return CYCLE_TYPE_OPTIONS.find((item) => item.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
function cycleStatusLabel(status: string) {
|
||||
return status === "active" ? "进行中" : status === "completed" ? "已完成" : "草稿";
|
||||
}
|
||||
|
||||
export function PerformancePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedCycleId, setSelectedCycleId] = useState("");
|
||||
const [cycleDialogOpen, setCycleDialogOpen] = useState(false);
|
||||
const [editingCycleId, setEditingCycleId] = useState<string | null>(null);
|
||||
const [cycleForm, setCycleForm] = useState<CycleFormState>(buildEmptyCycleForm());
|
||||
|
||||
const cyclesQuery = useQuery({
|
||||
queryKey: ["performance-cycles"],
|
||||
queryFn: fetchPerformanceCycles,
|
||||
});
|
||||
|
||||
const cycles = cyclesQuery.data?.items ?? [];
|
||||
const selectedCycle = cycles.find((item) => item.id === selectedCycleId) ?? cycles[0] ?? null;
|
||||
|
||||
const overviewQuery = useQuery({
|
||||
enabled: Boolean(selectedCycle?.id),
|
||||
queryKey: ["performance-cycle-overview", selectedCycle?.id],
|
||||
queryFn: () => fetchPerformanceCycleOverview(selectedCycle!.id),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!cycles.length) {
|
||||
setSelectedCycleId("");
|
||||
return;
|
||||
}
|
||||
if (!cycles.some((item) => item.id === selectedCycleId)) {
|
||||
const preferred = cycles.find((item) => item.status === "active") ?? cycles[0];
|
||||
setSelectedCycleId(preferred.id);
|
||||
}
|
||||
}, [cycles, selectedCycleId]);
|
||||
|
||||
const cycleMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (editingCycleId) {
|
||||
return patchPerformanceCycle(editingCycleId, cycleForm);
|
||||
}
|
||||
return createPerformanceCycle(cycleForm);
|
||||
},
|
||||
onSuccess: async (cycle) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["performance-cycles"] });
|
||||
setSelectedCycleId(cycle.id);
|
||||
setCycleDialogOpen(false);
|
||||
setEditingCycleId(null);
|
||||
setCycleForm(buildEmptyCycleForm());
|
||||
toast.success(editingCycleId ? "考核周期已更新" : "考核周期已创建");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, editingCycleId ? "更新周期失败" : "创建周期失败")),
|
||||
});
|
||||
|
||||
const activateMutation = useMutation({
|
||||
mutationFn: (cycleId: string) => patchPerformanceCycle(cycleId, { status: "active" }),
|
||||
onSuccess: async (cycle) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["performance-cycles"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["employee-stats"] }),
|
||||
]);
|
||||
setSelectedCycleId(cycle.id);
|
||||
toast.success("考核周期已激活");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "激活周期失败")),
|
||||
});
|
||||
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: (cycleId: string) => patchPerformanceCycle(cycleId, { status: "completed" }),
|
||||
onSuccess: async (cycle) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["performance-cycles"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["performance-cycle-overview", cycle.id] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["employee-stats"] }),
|
||||
]);
|
||||
setSelectedCycleId(cycle.id);
|
||||
toast.success("考核周期已关闭");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "关闭周期失败")),
|
||||
});
|
||||
|
||||
const reviewedItems = overviewQuery.data?.items.filter((item) => item.review) ?? [];
|
||||
const averageScore = reviewedItems.length
|
||||
? reviewedItems.reduce((sum, item) => sum + (item.review?.overallScore ?? 0), 0) / reviewedItems.length
|
||||
: null;
|
||||
|
||||
function openCreateDialog() {
|
||||
setEditingCycleId(null);
|
||||
setCycleForm(buildEmptyCycleForm());
|
||||
setCycleDialogOpen(true);
|
||||
}
|
||||
|
||||
function openEditDialog(cycle = selectedCycle) {
|
||||
if (!cycle) {
|
||||
return;
|
||||
}
|
||||
setEditingCycleId(cycle.id);
|
||||
setCycleForm({
|
||||
name: cycle.name,
|
||||
cycleType: cycle.cycleType,
|
||||
startDate: cycle.startDate ?? "",
|
||||
endDate: cycle.endDate ?? "",
|
||||
});
|
||||
setCycleDialogOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="绩效考核"
|
||||
subtitle="管理考核周期,查看当前或历史周期的全员评分概览。"
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild variant="secondary" type="button">
|
||||
<Link to="/people">返回人员管理</Link>
|
||||
</Button>
|
||||
<Button type="button" onClick={openCreateDialog}>
|
||||
新建周期
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="card-std p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-zinc-900">考核周期列表</h2>
|
||||
<p className="mt-1 text-sm text-zinc-500">draft 周期可编辑并激活;active 周期可关闭为 completed。</p>
|
||||
</div>
|
||||
<div className="text-sm text-zinc-500">{cycles.length} 个周期</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>日期</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{cycles.length ? (
|
||||
cycles.map((cycle) => (
|
||||
<TableRow
|
||||
key={cycle.id}
|
||||
className={cycle.id === selectedCycle?.id ? "bg-zinc-50" : undefined}
|
||||
onClick={() => setSelectedCycleId(cycle.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{cycle.name}</div>
|
||||
</TableCell>
|
||||
<TableCell>{cycleTypeLabel(cycle.cycleType)}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={cycle.status} label={cycleStatusLabel(cycle.status)} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{cycle.startDate ?? "-"} 至 {cycle.endDate ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => setSelectedCycleId(cycle.id)}>
|
||||
查看概览
|
||||
</Button>
|
||||
{cycle.status === "draft" ? (
|
||||
<>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => openEditDialog(cycle)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => activateMutation.mutate(cycle.id)}
|
||||
disabled={activateMutation.isPending}
|
||||
>
|
||||
激活
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{cycle.status === "active" ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => completeMutation.mutate(cycle.id)}
|
||||
disabled={completeMutation.isPending}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-4 text-sm text-zinc-500">
|
||||
{cyclesQuery.isLoading ? "加载考核周期中..." : "暂无考核周期"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedCycle ? (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<section className="card-std p-5">
|
||||
<div className="text-sm text-zinc-500">当前查看周期</div>
|
||||
<div className="mt-2 text-xl font-semibold text-zinc-900">{selectedCycle.name}</div>
|
||||
<div className="mt-2 text-xs text-zinc-500">
|
||||
{selectedCycle.startDate ?? "-"} 至 {selectedCycle.endDate ?? "-"}
|
||||
</div>
|
||||
</section>
|
||||
<section className="card-std p-5">
|
||||
<div className="text-sm text-zinc-500">已评分人数</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-zinc-900">{reviewedItems.length}</div>
|
||||
<div className="mt-2 text-xs text-zinc-500">按当前概览中已有评分记录统计</div>
|
||||
</section>
|
||||
<section className="card-std p-5">
|
||||
<div className="text-sm text-zinc-500">平均分</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-zinc-900">{averageScore == null ? "-" : averageScore.toFixed(1)}</div>
|
||||
<div className="mt-2 text-xs text-zinc-500">仅统计已评分记录</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-zinc-900">评分概览</h2>
|
||||
<p className="mt-1 text-sm text-zinc-500">查看全员在当前选中周期的评分状态,点击可进入员工档案绩效标签。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>员工</TableHead>
|
||||
<TableHead>部门 / 职位</TableHead>
|
||||
<TableHead>总分</TableHead>
|
||||
<TableHead>评分人</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{overviewQuery.data?.items.length ? (
|
||||
overviewQuery.data.items.map((item) => (
|
||||
<TableRow key={item.employee.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.employee.name}</div>
|
||||
<div className="text-xs text-zinc-500">{item.employee.employeeNo}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>{item.employee.department?.name ?? "-"}</div>
|
||||
<div className="text-xs text-zinc-500">{item.employee.title}</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.review ? item.review.overallScore.toFixed(1) : "-"}</TableCell>
|
||||
<TableCell>{item.review?.reviewer?.name ?? "-"}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
status={item.review ? "completed" : "draft"}
|
||||
label={item.review ? "已评分" : selectedCycle.status === "active" ? "待评分" : "暂无评分"}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button asChild type="button" size="sm" variant="secondary">
|
||||
<Link to={`/people/${item.employee.id}?tab=performance`}>查看绩效</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-4 text-sm text-zinc-500">
|
||||
{overviewQuery.isLoading ? "加载评分概览中..." : "当前周期暂无可展示的评分数据"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Dialog open={cycleDialogOpen} onOpenChange={setCycleDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingCycleId ? "编辑考核周期" : "新建考核周期"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">周期名称</label>
|
||||
<Input value={cycleForm.name} onChange={(event) => setCycleForm((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={cycleForm.cycleType}
|
||||
onChange={(event) => setCycleForm((prev) => ({ ...prev, cycleType: event.target.value as PerformanceReviewCycleType }))}
|
||||
>
|
||||
{CYCLE_TYPE_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">开始日期</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={cycleForm.startDate}
|
||||
onChange={(event) => setCycleForm((prev) => ({ ...prev, startDate: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">结束日期</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={cycleForm.endDate}
|
||||
onChange={(event) => setCycleForm((prev) => ({ ...prev, endDate: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => cycleMutation.mutate()}
|
||||
disabled={cycleMutation.isPending || !cycleForm.name.trim() || !cycleForm.startDate || !cycleForm.endDate}
|
||||
>
|
||||
{cycleMutation.isPending ? "保存中..." : editingCycleId ? "保存修改" : "创建周期"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PerformancePage;
|
||||
在新工单中引用
屏蔽一个用户