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