410 行
16 KiB
TypeScript
410 行
16 KiB
TypeScript
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;
|