文件
hyb-oa/client/src/pages/PerformancePage.tsx
T

406 行
16 KiB
TypeScript

import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
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 { 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) {
return resolveUserErrorMessage(error, 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;