feat: 完成 R13/R14/R15/R16 小团队管理核心能力
这个提交包含在:
@@ -0,0 +1,247 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
completeEmployeeChecklistTask,
|
||||
fetchEmployeeProfile,
|
||||
patchEmployeeProfile,
|
||||
regularizeEmployee,
|
||||
skipEmployeeChecklistTask,
|
||||
startEmployeeOffboarding,
|
||||
transferEmployee,
|
||||
} from "../api/employees";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { toast } from "sonner";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function EmployeeProfilePage() {
|
||||
const { employeeId = "" } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const profileQuery = useQuery({
|
||||
enabled: Boolean(employeeId),
|
||||
queryKey: ["employee-profile", employeeId],
|
||||
queryFn: () => fetchEmployeeProfile(employeeId),
|
||||
});
|
||||
|
||||
const profile = profileQuery.data;
|
||||
const [form, setForm] = useState({
|
||||
workLocation: "",
|
||||
emergencyContactName: "",
|
||||
emergencyContactPhone: "",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!profile) return;
|
||||
setForm({
|
||||
workLocation: profile.employee.workLocation ?? "",
|
||||
emergencyContactName: profile.employee.emergencyContactName ?? "",
|
||||
emergencyContactPhone: profile.employee.emergencyContactPhone ?? "",
|
||||
notes: profile.employee.notes ?? "",
|
||||
});
|
||||
}, [profile]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => patchEmployeeProfile(employeeId, { ...form }),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] });
|
||||
toast.success("档案已保存");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "保存失败")),
|
||||
});
|
||||
|
||||
const taskMutation = useMutation({
|
||||
mutationFn: ({ taskId, action }: { taskId: string; action: "complete" | "skip" }) =>
|
||||
action === "complete"
|
||||
? completeEmployeeChecklistTask(employeeId, taskId)
|
||||
: skipEmployeeChecklistTask(employeeId, taskId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] });
|
||||
toast.success("清单已更新");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "更新清单失败")),
|
||||
});
|
||||
|
||||
const regularizeMutation = useMutation({
|
||||
mutationFn: () => regularizeEmployee(employeeId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] });
|
||||
toast.success("已转正");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "转正失败")),
|
||||
});
|
||||
|
||||
const transferMutation = useMutation({
|
||||
mutationFn: () => transferEmployee(employeeId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] });
|
||||
toast.success("已记录调岗事件");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "调岗失败")),
|
||||
});
|
||||
|
||||
const offboardingMutation = useMutation({
|
||||
mutationFn: () => startEmployeeOffboarding(employeeId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] });
|
||||
toast.success("已发起离职");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "离职失败")),
|
||||
});
|
||||
|
||||
if (profileQuery.isLoading || !profile) {
|
||||
return <div className="card-std p-6 text-sm text-zinc-500">加载员工档案中...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={`${profile.employee.name} 档案`}
|
||||
subtitle={`${profile.employee.employeeNo} · ${profile.employee.title}`}
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => regularizeMutation.mutate()} disabled={regularizeMutation.isPending}>
|
||||
转正
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => transferMutation.mutate()} disabled={transferMutation.isPending}>
|
||||
记录调岗
|
||||
</Button>
|
||||
<Button type="button" onClick={() => offboardingMutation.mutate()} disabled={offboardingMutation.isPending}>
|
||||
发起离职
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<div className="card-std p-6 space-y-4 xl:col-span-2">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500">账号状态</div>
|
||||
<div className="mt-2 flex gap-2"><StatusBadge status={profile.employee.status} /><StatusBadge status={profile.employee.employmentStatus} /></div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500">部门 / 上级</div>
|
||||
<div className="mt-2 text-sm text-zinc-900">{profile.employee.department?.name ?? "-"} / {profile.employee.directManager?.name ?? "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500">入职 / 试用期</div>
|
||||
<div className="mt-2 text-sm text-zinc-900">{profile.employee.hireDate ?? "-"} / {profile.employee.probationEndDate ?? "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500">离职日期</div>
|
||||
<div className="mt-2 text-sm text-zinc-900">{profile.employee.lastWorkingDate ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">办公地点</label>
|
||||
<Input value={form.workLocation} onChange={(event) => setForm((prev) => ({ ...prev, workLocation: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">紧急联系人</label>
|
||||
<Input value={form.emergencyContactName} onChange={(event) => setForm((prev) => ({ ...prev, emergencyContactName: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">紧急联系电话</label>
|
||||
<Input value={form.emergencyContactPhone} onChange={(event) => setForm((prev) => ({ ...prev, emergencyContactPhone: event.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">备注</label>
|
||||
<Textarea value={form.notes} onChange={(event) => setForm((prev) => ({ ...prev, notes: event.target.value }))} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>保存档案</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<h3 className="text-base font-semibold text-zinc-900">离职阻塞项</h3>
|
||||
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
资产未归还:<span className="font-medium text-zinc-900">{profile.lifecycle.blockers.openAssetCount}</span>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
待办审批任务:<span className="font-medium text-zinc-900">{profile.lifecycle.blockers.pendingWorkflowTaskCount}</span>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
可完成离职:<span className="font-medium text-zinc-900">{profile.lifecycle.blockers.canCompleteOffboarding ? "是" : "否"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<div className="card-std p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">生命周期事件</h3>
|
||||
<div className="mt-4 space-y-3">
|
||||
{profile.lifecycle.events.length ? profile.lifecycle.events.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
<div className="font-medium text-zinc-900">{item.eventType}</div>
|
||||
<div className="mt-1 text-zinc-500">生效日期:{item.effectiveDate ?? "-"}</div>
|
||||
</div>
|
||||
)) : <div className="text-sm text-zinc-500">暂无生命周期事件</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">名下资产</h3>
|
||||
<div className="mt-4 space-y-3">
|
||||
{profile.assignedAssets.length ? profile.assignedAssets.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
<div className="font-medium text-zinc-900">{item.assetNo} · {item.name}</div>
|
||||
<div className="mt-1 text-zinc-500">位置:{item.location ?? "-"} · 状态:{item.status}</div>
|
||||
</div>
|
||||
)) : <div className="text-sm text-zinc-500">当前无挂名资产</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">Checklist</h3>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>场景</TableHead>
|
||||
<TableHead>任务</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{profile.lifecycle.checklistTasks.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{item.scene}</TableCell>
|
||||
<TableCell>{item.title}</TableCell>
|
||||
<TableCell><StatusBadge status={item.status} /></TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" type="button" disabled={taskMutation.isPending || item.status !== "pending"} onClick={() => taskMutation.mutate({ taskId: item.id, action: "complete" })}>完成</Button>
|
||||
<Button size="sm" variant="secondary" type="button" disabled={taskMutation.isPending || item.status !== "pending"} onClick={() => taskMutation.mutate({ taskId: item.id, action: "skip" })}>跳过</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmployeeProfilePage;
|
||||
在新工单中引用
屏蔽一个用户