R46 A1-A7:落地绩效考核模块首版
这个提交包含在:
@@ -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 = {
|
||||
|
||||
@@ -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<CreatePerformanceCyclePayload> & {
|
||||
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<PerformanceReviewCycle>("/api/performance/cycles", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function patchPerformanceCycle(cycleId: string, payload: PatchPerformanceCyclePayload) {
|
||||
return apiRequestData<PerformanceReviewCycle>(`/api/performance/cycles/${cycleId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchPerformanceCycleOverview(cycleId: string) {
|
||||
return apiRequestData<PerformanceCycleOverview>(`/api/performance/cycles/${cycleId}/reviews`);
|
||||
}
|
||||
|
||||
export async function fetchEmployeePerformanceTimeline(employeeId: string) {
|
||||
return apiRequestData<PerformanceEmployeeReviewTimeline>(`/api/performance/employees/${employeeId}/reviews`);
|
||||
}
|
||||
|
||||
export async function upsertPerformanceReview(cycleId: string, employeeId: string, payload: UpsertPerformanceReviewPayload) {
|
||||
return apiRequestData<PerformanceReview>(`/api/performance/cycles/${cycleId}/reviews/${employeeId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
completeEmployeeChecklistTask,
|
||||
@@ -30,10 +30,12 @@ import {
|
||||
type EmployeeTrainingStatus,
|
||||
type EmployeeTrainingType,
|
||||
} from "../api/employees";
|
||||
import { fetchEmployeePerformanceTimeline } from "../api/performance";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { EmployeePerformancePanel } from "../components/people/EmployeePerformancePanel";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
@@ -45,7 +47,7 @@ import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { isApiError } from "../api/types";
|
||||
import { downloadBlob } from "../lib/download";
|
||||
|
||||
type ProfileTab = "profile" | "contracts" | "trainingCertificates";
|
||||
type ProfileTab = "profile" | "contracts" | "trainingCertificates" | "performance";
|
||||
|
||||
type ContractFormState = {
|
||||
contractNo: string;
|
||||
@@ -119,6 +121,13 @@ function buildEmptyCertificateForm(): CertificateFormState {
|
||||
};
|
||||
}
|
||||
|
||||
function parseProfileTab(value: string | null): ProfileTab {
|
||||
if (value === "contracts" || value === "trainingCertificates" || value === "performance") {
|
||||
return value;
|
||||
}
|
||||
return "profile";
|
||||
}
|
||||
|
||||
function contractTypeLabel(type: EmployeeContractType) {
|
||||
return CONTRACT_TYPE_OPTIONS.find((item) => item.value === type)?.label ?? type;
|
||||
}
|
||||
@@ -142,8 +151,10 @@ function trainingTypeLabel(type: EmployeeTrainingType) {
|
||||
|
||||
export function EmployeeProfilePage() {
|
||||
const { employeeId = "" } = useParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { permissions, me } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const requestedTab = parseProfileTab(searchParams.get("tab"));
|
||||
const profileQuery = useQuery({
|
||||
enabled: Boolean(employeeId),
|
||||
queryKey: ["employee-profile", employeeId],
|
||||
@@ -154,7 +165,7 @@ export function EmployeeProfilePage() {
|
||||
const canEditBaseFields = canManageEmployeeProfile || canEditSelfProfile;
|
||||
const canEditExtendedFields = canManageEmployeeProfile;
|
||||
const canUploadAvatar = canManageEmployeeProfile || canEditSelfProfile;
|
||||
const [activeTab, setActiveTab] = useState<ProfileTab>("profile");
|
||||
const [activeTab, setActiveTab] = useState<ProfileTab>(requestedTab);
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [avatarInputKey, setAvatarInputKey] = useState(0);
|
||||
const [editingContractId, setEditingContractId] = useState<string | null>(null);
|
||||
@@ -192,6 +203,22 @@ export function EmployeeProfilePage() {
|
||||
queryKey: ["employee-certificates", employeeId],
|
||||
queryFn: () => fetchEmployeeCertificates(employeeId),
|
||||
});
|
||||
const performanceQuery = useQuery({
|
||||
enabled: Boolean(employeeId) && activeTab === "performance",
|
||||
queryKey: ["employee-performance", employeeId],
|
||||
queryFn: () => fetchEmployeePerformanceTimeline(employeeId),
|
||||
});
|
||||
|
||||
function updateActiveTab(nextTab: ProfileTab) {
|
||||
setActiveTab(nextTab);
|
||||
const nextSearchParams = new URLSearchParams(searchParams);
|
||||
if (nextTab === "profile") {
|
||||
nextSearchParams.delete("tab");
|
||||
} else {
|
||||
nextSearchParams.set("tab", nextTab);
|
||||
}
|
||||
setSearchParams(nextSearchParams, { replace: true });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!profileQuery.data) {
|
||||
@@ -210,11 +237,15 @@ export function EmployeeProfilePage() {
|
||||
}, [profileQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (canManageEmployeeProfile || activeTab === "profile") {
|
||||
setActiveTab(requestedTab);
|
||||
}, [employeeId, requestedTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (canManageEmployeeProfile || canEditSelfProfile || activeTab === "profile" || activeTab === "performance") {
|
||||
return;
|
||||
}
|
||||
setActiveTab("profile");
|
||||
}, [activeTab, canManageEmployeeProfile]);
|
||||
updateActiveTab("profile");
|
||||
}, [activeTab, canEditSelfProfile, canManageEmployeeProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
setAvatarFile(null);
|
||||
@@ -227,6 +258,7 @@ export function EmployeeProfilePage() {
|
||||
setTrainingForm(buildEmptyTrainingForm());
|
||||
setEditingCertificateId(null);
|
||||
setCertificateForm(buildEmptyCertificateForm());
|
||||
setActiveTab(requestedTab);
|
||||
}, [employeeId]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
@@ -445,10 +477,76 @@ export function EmployeeProfilePage() {
|
||||
onError: (error) => toast.error(errorMessage(error, editingCertificateId ? "更新证书记录失败" : "新增证书记录失败")),
|
||||
});
|
||||
|
||||
if (profileQuery.isLoading) {
|
||||
const profilePermissionDenied =
|
||||
profileQuery.isError && isApiError(profileQuery.error) && profileQuery.error.status === 403;
|
||||
const performanceOnlyMode = activeTab === "performance" && profilePermissionDenied && Boolean(performanceQuery.data);
|
||||
const showPerformanceTab =
|
||||
canManageEmployeeProfile || canEditSelfProfile || performanceOnlyMode || performanceQuery.data?.viewerRole === "manager";
|
||||
|
||||
const performanceContent =
|
||||
performanceQuery.isLoading ? (
|
||||
<div className="card-std p-6 text-sm text-zinc-500">加载绩效记录中...</div>
|
||||
) : performanceQuery.isError || !performanceQuery.data ? (
|
||||
<div className="card-std p-6 text-sm text-red-600">{errorMessage(performanceQuery.error, "加载绩效记录失败")}</div>
|
||||
) : (
|
||||
<EmployeePerformancePanel
|
||||
employeeId={employeeId}
|
||||
timeline={performanceQuery.data}
|
||||
invalidateKeys={[
|
||||
["employee-performance", employeeId],
|
||||
["employee-stats"],
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
if (profileQuery.isLoading || (activeTab === "performance" && profilePermissionDenied && performanceQuery.isLoading)) {
|
||||
return <div className="card-std p-6 text-sm text-zinc-500">加载员工档案中...</div>;
|
||||
}
|
||||
|
||||
if (performanceOnlyMode && performanceQuery.data) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={`${performanceQuery.data.employee.name} 绩效`}
|
||||
subtitle={`${performanceQuery.data.employee.employeeNo} · ${performanceQuery.data.employee.title}`}
|
||||
actions={
|
||||
<Button asChild variant="secondary" type="button">
|
||||
<Link to="/people">返回人员管理</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="card-std p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<EmployeeAvatar
|
||||
name={performanceQuery.data.employee.name}
|
||||
src={performanceQuery.data.employee.avatarUrl}
|
||||
size="xl"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-xl font-semibold text-zinc-900">{performanceQuery.data.employee.name}</div>
|
||||
<div className="mt-1 text-sm text-zinc-500">
|
||||
{performanceQuery.data.employee.employeeNo} · {performanceQuery.data.employee.department?.name ?? "-"}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<StatusBadge status={performanceQuery.data.employee.status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value="performance" onValueChange={() => {}}>
|
||||
<TabsList className="flex w-full flex-wrap justify-start gap-1">
|
||||
<TabsTrigger value="performance">绩效</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="performance" className="space-y-6">
|
||||
{performanceContent}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (profileQuery.isError || !profileQuery.data) {
|
||||
return <div className="card-std p-6 text-sm text-red-600">{errorMessage(profileQuery.error, "加载员工档案失败")}</div>;
|
||||
}
|
||||
@@ -582,11 +680,12 @@ export function EmployeeProfilePage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ProfileTab)}>
|
||||
<Tabs value={activeTab} onValueChange={(value) => updateActiveTab(value as ProfileTab)}>
|
||||
<TabsList className="flex w-full flex-wrap justify-start gap-1">
|
||||
<TabsTrigger value="profile">档案</TabsTrigger>
|
||||
{canManageEmployeeProfile ? <TabsTrigger value="contracts">合同</TabsTrigger> : null}
|
||||
{canManageEmployeeProfile ? <TabsTrigger value="trainingCertificates">培训与证书</TabsTrigger> : null}
|
||||
{showPerformanceTab ? <TabsTrigger value="performance">绩效</TabsTrigger> : null}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="profile" className="space-y-6">
|
||||
@@ -786,7 +885,14 @@ export function EmployeeProfilePage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={item.status} />
|
||||
<div className="flex items-center gap-2">
|
||||
{(canManageEmployeeProfile || canEditSelfProfile) && item.status === "active" ? (
|
||||
<Button asChild type="button" size="sm" variant="secondary">
|
||||
<Link to={`/people/${item.id}?tab=performance`}>绩效</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<StatusBadge status={item.status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -1308,6 +1414,12 @@ export function EmployeeProfilePage() {
|
||||
</div>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{showPerformanceTab ? (
|
||||
<TabsContent value="performance" className="space-y-6">
|
||||
{performanceContent}
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -292,6 +292,11 @@ export function PeoplePage() {
|
||||
subtitle="按当前员工档案与账号状态做筛选、批量处理和统计查看。"
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canManageUsers ? (
|
||||
<Button asChild variant="secondary" type="button">
|
||||
<Link to="/performance">绩效考核</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{canViewStats ? (
|
||||
<Button asChild variant="secondary" type="button">
|
||||
<Link to="/people/stats">员工统计</Link>
|
||||
|
||||
@@ -83,6 +83,22 @@ export function PeopleStatsPage() {
|
||||
{stats.certificateSummary.dateFrom} 至 {stats.certificateSummary.dateTo}
|
||||
</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">
|
||||
{stats.performanceSummary.averageScore == null ? "-" : stats.performanceSummary.averageScore.toFixed(1)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-zinc-500">
|
||||
{stats.performanceSummary.cycle
|
||||
? `${stats.performanceSummary.cycle.name} · ${stats.performanceSummary.cycle.startDate} 至 ${stats.performanceSummary.cycle.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">{stats.performanceSummary.reviewedCount}</div>
|
||||
<div className="mt-2 text-xs text-zinc-500">只统计当前 active 或最近 completed 周期</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
@@ -126,6 +142,25 @@ export function PeopleStatsPage() {
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="card-std p-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-zinc-900">绩效评分分布</h2>
|
||||
<p className="mt-1 text-sm text-zinc-500">按当前 active 周期或最近 completed 周期的总分分布汇总。</p>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-3 md:grid-cols-3 xl:grid-cols-5">
|
||||
{stats.performanceSummary.cycle && stats.performanceSummary.distribution.length ? (
|
||||
stats.performanceSummary.distribution.map((item) => (
|
||||
<div key={item.score} className="rounded-2xl border border-zinc-200 bg-white px-4 py-3">
|
||||
<div className="text-sm text-zinc-500">{item.score.toFixed(1)} 分</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-zinc-900">{item.count}</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-zinc-500">暂无绩效周期统计。</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
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;
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
NotificationChannelsPage,
|
||||
NotificationsPage,
|
||||
OrganizationPage,
|
||||
PerformancePage,
|
||||
PaymentBatchesPage,
|
||||
PeoplePage,
|
||||
PeopleStatsPage,
|
||||
@@ -304,6 +305,15 @@ export function AppRouter() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="performance"
|
||||
element={
|
||||
<RequirePermission anyOf={["system.users"]}>
|
||||
{withRouteFallback(<PerformancePage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="organization"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { PerformancePage as default } from "../../pages/PerformancePage";
|
||||
@@ -22,6 +22,7 @@ export const AnnouncementsPage = lazy(() => import("./AnnouncementsPage"));
|
||||
export const PaymentBatchesPage = lazy(() => import("./PaymentBatchesPage"));
|
||||
export const PeoplePage = lazy(() => import("./PeoplePage"));
|
||||
export const PeopleStatsPage = lazy(() => import("./PeopleStatsPage"));
|
||||
export const PerformancePage = lazy(() => import("./PerformancePage"));
|
||||
export const OrganizationPage = lazy(() => import("./OrganizationPage"));
|
||||
export const EmployeeProfilePage = lazy(() => import("./EmployeeProfilePage"));
|
||||
export const TeamPresencePage = lazy(() => import("./TeamPresencePage"));
|
||||
|
||||
在新工单中引用
屏蔽一个用户