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

这个提交包含在:
2026-03-29 16:30:29 +08:00
父节点 48f395e18d
当前提交 659e3153a3
共修改 19 个文件,包含 2652 行新增和 15 行删除
@@ -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;