From 659e3153a3fb32271dffea4f575db2a158b2ad8e Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 29 Mar 2026 16:30:29 +0800 Subject: [PATCH] =?UTF-8?q?R46=20A1-A7=EF=BC=9A=E8=90=BD=E5=9C=B0=E7=BB=A9?= =?UTF-8?q?=E6=95=88=E8=80=83=E6=A0=B8=E6=A8=A1=E5=9D=97=E9=A6=96=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/employees.ts | 16 + client/src/api/performance.ts | 128 ++++ .../people/EmployeePerformancePanel.tsx | 292 ++++++++ client/src/pages/EmployeeProfilePage.tsx | 130 +++- client/src/pages/PeoplePage.tsx | 5 + client/src/pages/PeopleStatsPage.tsx | 35 + client/src/pages/PerformancePage.tsx | 409 +++++++++++ client/src/router/index.tsx | 10 + client/src/router/lazy/PerformancePage.ts | 1 + client/src/router/lazy/index.ts | 1 + issues/2026-03-29_r46-performance-review.csv | 9 + .../migration.sql | 88 +++ server/prisma/schema.prisma | 88 ++- server/src/app.js | 2 + server/src/routes/performance.js | 661 ++++++++++++++++++ server/src/services/employeeStats.js | 63 +- .../tests/unit/routes/employees.stats.test.js | 85 ++- .../unit/routes/performance.cycles.test.js | 248 +++++++ .../unit/routes/performance.reviews.test.js | 396 +++++++++++ 19 files changed, 2652 insertions(+), 15 deletions(-) create mode 100644 client/src/api/performance.ts create mode 100644 client/src/components/people/EmployeePerformancePanel.tsx create mode 100644 client/src/pages/PerformancePage.tsx create mode 100644 client/src/router/lazy/PerformancePage.ts create mode 100644 issues/2026-03-29_r46-performance-review.csv create mode 100644 server/prisma/migrations/20260329230000_r46_performance_reviews/migration.sql create mode 100644 server/src/routes/performance.js create mode 100644 server/tests/unit/routes/performance.cycles.test.js create mode 100644 server/tests/unit/routes/performance.reviews.test.js diff --git a/client/src/api/employees.ts b/client/src/api/employees.ts index 84586b70..874e64f1 100644 --- a/client/src/api/employees.ts +++ b/client/src/api/employees.ts @@ -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 = { diff --git a/client/src/api/performance.ts b/client/src/api/performance.ts new file mode 100644 index 00000000..e8cdb6a8 --- /dev/null +++ b/client/src/api/performance.ts @@ -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 & { + 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("/api/performance/cycles", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function patchPerformanceCycle(cycleId: string, payload: PatchPerformanceCyclePayload) { + return apiRequestData(`/api/performance/cycles/${cycleId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + +export async function fetchPerformanceCycleOverview(cycleId: string) { + return apiRequestData(`/api/performance/cycles/${cycleId}/reviews`); +} + +export async function fetchEmployeePerformanceTimeline(employeeId: string) { + return apiRequestData(`/api/performance/employees/${employeeId}/reviews`); +} + +export async function upsertPerformanceReview(cycleId: string, employeeId: string, payload: UpsertPerformanceReviewPayload) { + return apiRequestData(`/api/performance/cycles/${cycleId}/reviews/${employeeId}`, { + method: "PUT", + body: JSON.stringify(payload), + }); +} diff --git a/client/src/components/people/EmployeePerformancePanel.tsx b/client/src/components/people/EmployeePerformancePanel.tsx new file mode 100644 index 00000000..53c92193 --- /dev/null +++ b/client/src/components/people/EmployeePerformancePanel.tsx @@ -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; +}; + +type EmployeePerformancePanelProps = { + employeeId: string; + timeline: PerformanceEmployeeReviewTimeline; + invalidateKeys?: Array; +}; + +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 { + 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(timeline.items[0]?.cycle.id ?? ""); + const selectedItem = timeline.items.find((item) => item.cycle.id === selectedCycleId) ?? timeline.items[0] ?? null; + const [form, setForm] = useState(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
暂无可展示的绩效记录。
; + } + + return ( +
+
+
+
+

考核周期

+

+ {timeline.viewerRole === "self" ? "仅展示已完成且本人可见的结果。" : "可查看历史周期,并在进行中周期填写或修改评分。"} +

+
+
{timeline.items.length} 个周期
+
+ +
+ {timeline.items.map((item) => ( + + ))} +
+
+ + {selectedItem ? ( +
+
+
+

{selectedItem.cycle.name}

+

+ {selectedItem.cycle.startDate ?? "-"} 至 {selectedItem.cycle.endDate ?? "-"} +

+
+ +
+ + {selectedItem.canEdit ? ( + <> +
+
+ + setForm((prev) => ({ ...prev, overallScore: event.target.value }))} + /> +
+
+ +
+ +