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

这个提交包含在:
2026-03-29 16:30:29 +08:00
父节点 48f395e18d
当前提交 659e3153a3
共修改 19 个文件,包含 2652 行新增和 15 行删除
+16
查看文件
@@ -250,6 +250,22 @@ export type EmployeeStatsResponse = {
validHolderCount: number;
expiringSoonCount: number;
};
performanceSummary: {
cycle: {
id: string;
name: string;
cycleType: "monthly" | "quarterly" | "semi_annual" | "annual";
status: "draft" | "active" | "completed";
startDate: string | null;
endDate: string | null;
} | null;
averageScore: number | null;
reviewedCount: number;
distribution: Array<{
score: number;
count: number;
}>;
};
};
export type BatchTransferDepartmentPayload = {
+128
查看文件
@@ -0,0 +1,128 @@
import { apiRequestData } from "./http";
import type { EmployeeDepartmentSummary, EmployeeStatus } from "./employees";
export type PerformanceReviewCycleType = "monthly" | "quarterly" | "semi_annual" | "annual";
export type PerformanceReviewCycleStatus = "draft" | "active" | "completed";
export type PerformanceReviewDimensionType =
| "work_quality"
| "work_efficiency"
| "teamwork"
| "initiative"
| "professional_skill";
export type PerformanceEmployeeSummary = {
id: string;
employeeNo: string;
name: string;
title: string;
status: EmployeeStatus;
avatarUrl: string | null;
department: EmployeeDepartmentSummary | null;
};
export type PerformanceReviewCycle = {
id: string;
name: string;
cycleType: PerformanceReviewCycleType;
status: PerformanceReviewCycleStatus;
startDate: string | null;
endDate: string | null;
activatedAt: string | null;
completedAt: string | null;
createdAt: string | null;
createdBy: string | null;
updatedAt: string | null;
updatedBy: string | null;
};
export type PerformanceReviewDimension = {
dimensionType: PerformanceReviewDimensionType;
score: number;
};
export type PerformanceReview = {
id: string;
cycleId: string;
employeeId: string;
reviewerEmployeeId: string;
overallScore: number;
comment: string | null;
reviewer: {
id: string;
employeeNo: string;
name: string;
} | null;
dimensions: PerformanceReviewDimension[];
createdAt: string | null;
createdBy: string | null;
updatedAt: string | null;
updatedBy: string | null;
};
export type PerformanceCycleOverview = {
cycle: PerformanceReviewCycle;
items: Array<{
employee: PerformanceEmployeeSummary;
review: PerformanceReview | null;
}>;
};
export type PerformanceEmployeeReviewTimeline = {
employee: PerformanceEmployeeSummary;
viewerRole: "self" | "manager" | "admin";
items: Array<{
cycle: PerformanceReviewCycle;
review: PerformanceReview | null;
canEdit: boolean;
}>;
};
export type CreatePerformanceCyclePayload = {
name: string;
cycleType: PerformanceReviewCycleType;
startDate: string;
endDate: string;
};
export type PatchPerformanceCyclePayload = Partial<CreatePerformanceCyclePayload> & {
status?: PerformanceReviewCycleStatus;
};
export type UpsertPerformanceReviewPayload = {
overallScore: number;
comment?: string | null;
dimensions?: PerformanceReviewDimension[];
};
export async function fetchPerformanceCycles() {
return apiRequestData<{ items: PerformanceReviewCycle[] }>("/api/performance/cycles");
}
export async function createPerformanceCycle(payload: CreatePerformanceCyclePayload) {
return apiRequestData<PerformanceReviewCycle>("/api/performance/cycles", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function patchPerformanceCycle(cycleId: string, payload: PatchPerformanceCyclePayload) {
return apiRequestData<PerformanceReviewCycle>(`/api/performance/cycles/${cycleId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export async function fetchPerformanceCycleOverview(cycleId: string) {
return apiRequestData<PerformanceCycleOverview>(`/api/performance/cycles/${cycleId}/reviews`);
}
export async function fetchEmployeePerformanceTimeline(employeeId: string) {
return apiRequestData<PerformanceEmployeeReviewTimeline>(`/api/performance/employees/${employeeId}/reviews`);
}
export async function upsertPerformanceReview(cycleId: string, employeeId: string, payload: UpsertPerformanceReviewPayload) {
return apiRequestData<PerformanceReview>(`/api/performance/cycles/${cycleId}/reviews/${employeeId}`, {
method: "PUT",
body: JSON.stringify(payload),
});
}
@@ -0,0 +1,292 @@
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
upsertPerformanceReview,
type PerformanceEmployeeReviewTimeline,
type PerformanceReviewDimensionType,
} from "../../api/performance";
import { mapApiErrorMessage } from "../../api/errorMessages";
import { isApiError } from "../../api/types";
import { StatusBadge } from "../common/StatusBadge";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Textarea } from "../ui/textarea";
import { toast } from "sonner";
type PerformanceFormState = {
overallScore: string;
comment: string;
dimensions: Record<PerformanceReviewDimensionType, string>;
};
type EmployeePerformancePanelProps = {
employeeId: string;
timeline: PerformanceEmployeeReviewTimeline;
invalidateKeys?: Array<readonly unknown[]>;
};
const DIMENSION_OPTIONS: Array<{ value: PerformanceReviewDimensionType; label: string }> = [
{ value: "work_quality", label: "工作质量" },
{ value: "work_efficiency", label: "工作效率" },
{ value: "teamwork", label: "团队协作" },
{ value: "initiative", label: "主动性" },
{ value: "professional_skill", label: "专业能力" },
];
function buildEmptyDimensions(): Record<PerformanceReviewDimensionType, string> {
return {
work_quality: "",
work_efficiency: "",
teamwork: "",
initiative: "",
professional_skill: "",
};
}
function buildFormState(review: PerformanceEmployeeReviewTimeline["items"][number]["review"]): PerformanceFormState {
const dimensions = buildEmptyDimensions();
for (const item of review?.dimensions ?? []) {
dimensions[item.dimensionType] = String(item.score);
}
return {
overallScore: review ? String(review.overallScore) : "",
comment: review?.comment ?? "",
dimensions,
};
}
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 cycleStatusLabel(status: string) {
return status === "active" ? "进行中" : status === "completed" ? "已完成" : "草稿";
}
export function EmployeePerformancePanel({ employeeId, timeline, invalidateKeys = [] }: EmployeePerformancePanelProps) {
const queryClient = useQueryClient();
const [selectedCycleId, setSelectedCycleId] = useState<string>(timeline.items[0]?.cycle.id ?? "");
const selectedItem = timeline.items.find((item) => item.cycle.id === selectedCycleId) ?? timeline.items[0] ?? null;
const [form, setForm] = useState<PerformanceFormState>(buildFormState(selectedItem?.review ?? null));
useEffect(() => {
if (!timeline.items.length) {
setSelectedCycleId("");
return;
}
if (!timeline.items.some((item) => item.cycle.id === selectedCycleId)) {
setSelectedCycleId(timeline.items[0].cycle.id);
}
}, [selectedCycleId, timeline.items]);
useEffect(() => {
setForm(buildFormState(selectedItem?.review ?? null));
}, [selectedItem?.cycle.id, selectedItem?.review?.updatedAt]);
const saveMutation = useMutation({
mutationFn: async () => {
if (!selectedItem) {
throw new Error("未选中考核周期");
}
return upsertPerformanceReview(selectedItem.cycle.id, employeeId, {
overallScore: Number(form.overallScore),
comment: form.comment.trim() ? form.comment.trim() : null,
dimensions: DIMENSION_OPTIONS.flatMap((item) => {
const raw = form.dimensions[item.value].trim();
return raw
? [
{
dimensionType: item.value,
score: Number(raw),
},
]
: [];
}),
});
},
onSuccess: async () => {
await Promise.all(
invalidateKeys.map((queryKey) =>
queryClient.invalidateQueries({
queryKey: [...queryKey],
}),
),
);
toast.success("绩效评分已保存");
},
onError: (error) => toast.error(errorMessage(error, "保存绩效评分失败")),
});
if (!timeline.items.length) {
return <div className="card-std p-6 text-sm text-zinc-500">暂无可展示的绩效记录。</div>;
}
return (
<div className="grid gap-6 xl:grid-cols-[0.95fr,1.05fr]">
<section className="card-std p-6">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">考核周期</h3>
<p className="mt-1 text-sm text-zinc-500">
{timeline.viewerRole === "self" ? "仅展示已完成且本人可见的结果。" : "可查看历史周期,并在进行中周期填写或修改评分。"}
</p>
</div>
<div className="text-sm text-zinc-500">{timeline.items.length} 个周期</div>
</div>
<div className="mt-4 space-y-3">
{timeline.items.map((item) => (
<button
key={item.cycle.id}
type="button"
className={`w-full rounded-2xl border px-4 py-3 text-left transition ${
item.cycle.id === selectedItem?.cycle.id ? "border-zinc-900 bg-zinc-50" : "border-zinc-200 hover:border-zinc-300"
}`}
onClick={() => setSelectedCycleId(item.cycle.id)}
>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-zinc-900">{item.cycle.name}</div>
<div className="mt-1 text-xs text-zinc-500">
{item.cycle.startDate ?? "-"} 至 {item.cycle.endDate ?? "-"}
</div>
</div>
<StatusBadge status={item.cycle.status} label={cycleStatusLabel(item.cycle.status)} />
</div>
<div className="mt-3 flex items-center justify-between gap-3 text-sm">
<span className="text-zinc-500">{item.review ? "已评分" : item.canEdit ? "待评分" : "暂无评分"}</span>
<span className="font-medium text-zinc-900">{item.review ? `${item.review.overallScore.toFixed(1)} 分` : "-"}</span>
</div>
</button>
))}
</div>
</section>
{selectedItem ? (
<section className="card-std space-y-5 p-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">{selectedItem.cycle.name}</h3>
<p className="mt-1 text-sm text-zinc-500">
{selectedItem.cycle.startDate ?? "-"} 至 {selectedItem.cycle.endDate ?? "-"}
</p>
</div>
<StatusBadge status={selectedItem.cycle.status} label={cycleStatusLabel(selectedItem.cycle.status)} />
</div>
{selectedItem.canEdit ? (
<>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<label className="text-sm text-zinc-500">总分</label>
<Input
type="number"
min="1"
max="5"
step="0.5"
value={form.overallScore}
onChange={(event) => setForm((prev) => ({ ...prev, overallScore: event.target.value }))}
/>
</div>
</div>
<div className="grid gap-2">
<label className="text-sm text-zinc-500">评语</label>
<Textarea
value={form.comment}
onChange={(event) => setForm((prev) => ({ ...prev, comment: event.target.value }))}
placeholder="填写绩效反馈"
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
{DIMENSION_OPTIONS.map((item) => (
<div key={item.value} className="grid gap-2">
<label className="text-sm text-zinc-500">{item.label}</label>
<Input
type="number"
min="1"
max="5"
step="0.5"
placeholder="留空则不记录"
value={form.dimensions[item.value]}
onChange={(event) =>
setForm((prev) => ({
...prev,
dimensions: {
...prev.dimensions,
[item.value]: event.target.value,
},
}))
}
/>
</div>
))}
</div>
<div className="flex justify-end">
<Button
type="button"
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !form.overallScore.trim()}
>
{saveMutation.isPending ? "保存中..." : selectedItem.review ? "保存修改" : "提交评分"}
</Button>
</div>
</>
) : selectedItem.review ? (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-zinc-200 px-4 py-3">
<div className="text-sm text-zinc-500">总分</div>
<div className="mt-2 text-2xl font-semibold text-zinc-900">{selectedItem.review.overallScore.toFixed(1)}</div>
</div>
<div className="rounded-2xl border border-zinc-200 px-4 py-3">
<div className="text-sm text-zinc-500">评分人</div>
<div className="mt-2 text-sm font-medium text-zinc-900">
{selectedItem.review.reviewer?.name ?? "-"}
</div>
<div className="mt-1 text-xs text-zinc-500">{selectedItem.review.updatedAt ?? selectedItem.review.createdAt ?? "-"}</div>
</div>
</div>
<div className="rounded-2xl border border-zinc-200 px-4 py-3">
<div className="text-sm text-zinc-500">评语</div>
<div className="mt-2 whitespace-pre-wrap text-sm text-zinc-900">{selectedItem.review.comment ?? "-"}</div>
</div>
<div className="rounded-2xl border border-zinc-200 px-4 py-3">
<div className="text-sm text-zinc-500">考核维度</div>
<div className="mt-3 grid gap-3 md:grid-cols-2">
{selectedItem.review.dimensions.length ? (
selectedItem.review.dimensions.map((item) => (
<div key={item.dimensionType} className="flex items-center justify-between rounded-xl bg-zinc-50 px-3 py-2 text-sm">
<span className="text-zinc-700">
{DIMENSION_OPTIONS.find((option) => option.value === item.dimensionType)?.label ?? item.dimensionType}
</span>
<span className="font-semibold text-zinc-900">{item.score.toFixed(1)}</span>
</div>
))
) : (
<div className="text-sm text-zinc-500">未记录维度明细。</div>
)}
</div>
</div>
</div>
) : (
<div className="rounded-2xl border border-dashed border-zinc-300 px-4 py-6 text-sm text-zinc-500">
当前周期尚未生成评分记录。
</div>
)}
</section>
) : null}
</div>
);
}
export default EmployeePerformancePanel;
+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>
);
+5
查看文件
@@ -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>
+35
查看文件
@@ -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>
);
}
+409
查看文件
@@ -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;
+10
查看文件
@@ -32,6 +32,7 @@ import {
NotificationChannelsPage,
NotificationsPage,
OrganizationPage,
PerformancePage,
PaymentBatchesPage,
PeoplePage,
PeopleStatsPage,
@@ -304,6 +305,15 @@ export function AppRouter() {
}
/>
<Route
path="performance"
element={
<RequirePermission anyOf={["system.users"]}>
{withRouteFallback(<PerformancePage />)}
</RequirePermission>
}
/>
<Route
path="organization"
element={
@@ -0,0 +1 @@
export { PerformancePage as default } from "../../pages/PerformancePage";
+1
查看文件
@@ -22,6 +22,7 @@ export const AnnouncementsPage = lazy(() => import("./AnnouncementsPage"));
export const PaymentBatchesPage = lazy(() => import("./PaymentBatchesPage"));
export const PeoplePage = lazy(() => import("./PeoplePage"));
export const PeopleStatsPage = lazy(() => import("./PeopleStatsPage"));
export const PerformancePage = lazy(() => import("./PerformancePage"));
export const OrganizationPage = lazy(() => import("./OrganizationPage"));
export const EmployeeProfilePage = lazy(() => import("./EmployeeProfilePage"));
export const TeamPresencePage = lazy(() => import("./TeamPresencePage"));