HRWD-0..9 实现人事生命周期企微通讯录同步

这个提交包含在:
2026-05-17 21:01:28 +08:00
父节点 778132760f
当前提交 9ae42dadc1
共修改 24 个文件,包含 1553 行新增和 123 行删除
+165 -64
查看文件
@@ -9,9 +9,12 @@ import {
fetchEmployees,
fetchEmployeeOrganization,
regularizeEmployee,
retryEmployeeWecomDirectorySync,
startEmployeeOffboarding,
startEmployeeOnboarding,
type EmployeeEmploymentStatus,
type EmployeeSummary,
type WecomDirectoryState,
type OrganizationTreeNode,
} from "../api/employees";
import { exportEmployees } from "../api/exports";
@@ -59,6 +62,34 @@ function employmentStatusLabel(status: EmployeeEmploymentStatus | null) {
return EMPLOYMENT_STATUS_OPTIONS.find((item) => item.value === status)?.label ?? "-";
}
function wecomDirectoryStatusView(state?: WecomDirectoryState) {
const errorCode = state?.latestSync?.errorCode ?? "";
if (!state) {
return { label: "未同步", status: "not_triggered", hint: "无记录" };
}
if (state.status === "synced" || state.status === "sync_success") {
return { label: "同步成功", status: "succeeded", hint: state.externalUserId ?? "已绑定" };
}
if (state.status === "bound") {
return { label: "已绑定", status: "bound", hint: state.externalUserId ?? "已绑定" };
}
if (state.status === "sync_failed") {
return { label: "同步失败", status: "failed", hint: state.latestSync?.errorCode ?? "待重试" };
}
if (state.status === "sync_skipped") {
return {
label: errorCode === "wecom_contact_config_missing" || errorCode === "wecom_contact_sync_disabled" ? "配置缺失" : "已跳过",
status: "skipped",
hint: state.latestSync?.errorCode ?? "未执行",
};
}
return { label: "未绑定", status: "not_triggered", hint: "待创建" };
}
function shouldShowWecomRetry(state?: WecomDirectoryState) {
return !state || ["unbound", "sync_failed", "sync_skipped"].includes(state.status);
}
function collectOrganizationEmployees(nodes: OrganizationTreeNode[], target: Map<string, { id: string; name: string; employeeNo: string; departmentName: string }>) {
for (const node of nodes) {
if (!target.has(node.id)) {
@@ -93,6 +124,7 @@ export function PeoplePage() {
const [managerDialogOpen, setManagerDialogOpen] = useState(false);
const [enableConfirmOpen, setEnableConfirmOpen] = useState(false);
const [disableConfirmOpen, setDisableConfirmOpen] = useState(false);
const [offboardingTarget, setOffboardingTarget] = useState<EmployeeSummary | null>(null);
const [targetDepartmentId, setTargetDepartmentId] = useState("");
const [targetManagerId, setTargetManagerId] = useState("");
@@ -161,6 +193,7 @@ export function PeoplePage() {
mutationFn: (employeeId: string) => startEmployeeOffboarding(employeeId),
onSuccess: async () => {
await invalidatePeopleQueries();
setOffboardingTarget(null);
toast.success("已发起离职流程");
},
onError: (error) => {
@@ -168,6 +201,26 @@ export function PeoplePage() {
},
});
const wecomDirectoryRetryMutation = useMutation({
mutationFn: (employeeId: string) => retryEmployeeWecomDirectorySync(employeeId),
onSuccess: async (result) => {
await invalidatePeopleQueries();
const code = result.result?.code;
if (result.result?.skipped) {
toast.warning(code ? `企微同步已跳过:${code}` : "企微同步已跳过");
return;
}
if (result.result?.status === "failed") {
toast.error(code ? `企微同步失败:${code}` : "企微同步失败");
return;
}
toast.success("企微同步已提交");
},
onError: (error) => {
toast.error(mapApiMessage(error, "企微同步重试失败"));
},
});
const exportMutation = useMutation({
mutationFn: (format: "csv" | "xlsx") =>
exportEmployees({
@@ -266,8 +319,9 @@ export function PeoplePage() {
transferDepartmentMutation.isPending ||
setManagerMutation.isPending ||
batchEnableMutation.isPending ||
batchDisableMutation.isPending;
const tableColumnCount = canManageUsers ? 7 : 6;
batchDisableMutation.isPending ||
wecomDirectoryRetryMutation.isPending;
const tableColumnCount = canManageUsers ? 8 : 7;
function toggleSelection(employeeId: string, checked: boolean) {
setSelectedEmployeeIds((current) => {
@@ -470,78 +524,105 @@ export function PeoplePage() {
<TableHead>岗位 / 雇用状态</TableHead>
<TableHead>角色</TableHead>
<TableHead>账号状态</TableHead>
<TableHead>企业微信</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length ? (
items.map((item) => (
<TableRow key={item.id}>
{canManageUsers ? (
items.map((item) => {
const wecomView = wecomDirectoryStatusView(item.wecomDirectory);
return (
<TableRow key={item.id}>
{canManageUsers ? (
<TableCell>
<input
aria-label={`选择员工 ${item.name}`}
checked={selectedEmployeeIds.includes(item.id)}
className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400"
disabled={batchPending}
type="checkbox"
onChange={(event) => toggleSelection(item.id, event.target.checked)}
/>
</TableCell>
) : null}
<TableCell>
<input
aria-label={`选择员工 ${item.name}`}
checked={selectedEmployeeIds.includes(item.id)}
className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400"
disabled={batchPending}
type="checkbox"
onChange={(event) => toggleSelection(item.id, event.target.checked)}
/>
</TableCell>
) : null}
<TableCell>
<div className="flex items-center gap-3">
<EmployeeAvatar name={item.name} src={item.avatarUrl} />
<div>
<div className="font-medium text-zinc-900">{item.name}</div>
<div className="text-xs text-zinc-500">
{item.employeeNo} · {item.email}
<div className="flex items-center gap-3">
<EmployeeAvatar name={item.name} src={item.avatarUrl} />
<div>
<div className="font-medium text-zinc-900">{item.name}</div>
<div className="text-xs text-zinc-500">
{item.employeeNo} · {item.email}
</div>
</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div className="text-zinc-900">{item.department?.name ?? "-"}</div>
<div className="text-xs text-zinc-500">
上级:{item.directManager ? `${item.directManager.name} · ${item.directManager.employeeNo}` : "-"}
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div className="text-zinc-900">{item.department?.name ?? "-"}</div>
<div className="text-xs text-zinc-500">
上级:{item.directManager ? `${item.directManager.name} · ${item.directManager.employeeNo}` : "-"}
</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div className="text-zinc-900">{item.title || "-"}</div>
<div className="text-xs text-zinc-500">
{employmentStatusLabel(item.employmentStatus)} · 入职 {item.hireDate ?? "-"}
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div className="text-zinc-900">{item.title || "-"}</div>
<div className="text-xs text-zinc-500">
{employmentStatusLabel(item.employmentStatus)} · 入职 {item.hireDate ?? "-"}
</div>
</div>
</div>
</TableCell>
<TableCell>{item.roleCodes.join(" / ") || "-"}</TableCell>
<TableCell>
<StatusBadge status={item.status} />
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button asChild size="sm" variant="secondary" type="button">
<Link to={`/people/${item.id}`}>查看档案</Link>
</Button>
{canManageUsers ? (
<>
<Button size="sm" type="button" disabled={onboardingMutation.isPending} onClick={() => onboardingMutation.mutate(item.id)}>
发起入职
</Button>
<Button size="sm" variant="secondary" type="button" disabled={regularizeMutation.isPending} onClick={() => regularizeMutation.mutate(item.id)}>
转正
</Button>
<Button size="sm" variant="secondary" type="button" disabled={offboardingMutation.isPending} onClick={() => offboardingMutation.mutate(item.id)}>
发起离职
</Button>
</>
) : null}
</div>
</TableCell>
</TableRow>
))
</TableCell>
<TableCell>{item.roleCodes.join(" / ") || "-"}</TableCell>
<TableCell>
<StatusBadge status={item.status} />
</TableCell>
<TableCell>
<div className="space-y-1">
<StatusBadge status={wecomView.status} label={wecomView.label} />
<div className="max-w-40 truncate text-xs text-zinc-500">{wecomView.hint}</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button asChild size="sm" variant="secondary" type="button">
<Link to={`/people/${item.id}`}>查看档案</Link>
</Button>
{canManageUsers ? (
<>
<Button size="sm" type="button" disabled={onboardingMutation.isPending} onClick={() => onboardingMutation.mutate(item.id)}>
发起入职
</Button>
<Button size="sm" variant="secondary" type="button" disabled={regularizeMutation.isPending} onClick={() => regularizeMutation.mutate(item.id)}>
转正
</Button>
{shouldShowWecomRetry(item.wecomDirectory) ? (
<Button
size="sm"
variant="secondary"
type="button"
disabled={wecomDirectoryRetryMutation.isPending}
onClick={() => wecomDirectoryRetryMutation.mutate(item.id)}
>
企微重试
</Button>
) : null}
<Button
size="sm"
variant="secondary"
type="button"
disabled={offboardingMutation.isPending}
onClick={() => setOffboardingTarget(item)}
>
发起离职
</Button>
</>
) : null}
</div>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell className="py-3 text-zinc-500" colSpan={tableColumnCount}>
@@ -657,6 +738,26 @@ export function PeoplePage() {
await batchDisableMutation.mutateAsync();
}}
/>
<ConfirmDialog
open={Boolean(offboardingTarget)}
onOpenChange={(open) => {
if (!open) setOffboardingTarget(null);
}}
title="发起离职流程"
description={
offboardingTarget
? `将为 ${offboardingTarget.name} 发起离职清单;清单完成后会禁用 OA 账号并尝试禁用企业微信成员。`
: ""
}
confirmLabel={offboardingMutation.isPending ? "处理中..." : "确认发起"}
variant="destructive"
onConfirm={async () => {
if (offboardingTarget) {
await offboardingMutation.mutateAsync(offboardingTarget.id);
}
}}
/>
</div>
);
}