feat: 完成 R13/R14/R15/R16 小团队管理核心能力

这个提交包含在:
2026-03-21 20:31:34 +08:00
父节点 0826693a8f
当前提交 1e52f1c225
共修改 47 个文件,包含 4298 行新增和 306 行删除
+247
查看文件
@@ -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;