收敛企微主办 HR 考勤页面(05-30-wecom-hr-boundary-pages)

这个提交包含在:
2026-05-30 11:41:35 +08:00
父节点 98749c9d48
当前提交 83c8e8be4f
共修改 12 个文件,包含 507 行新增和 288 行删除
@@ -31,9 +31,9 @@ const WELCOME_LINKS: WelcomeLinkItem[] = [
},
{
icon: "event_available",
label: "请假",
description: "提交请假申请或查看自己的请假记录",
to: "/leave-requests?action=create",
label: "请假记录",
description: "日常请假走企业微信,OA 查看记录和状态",
to: "/leave-requests",
},
];
+26 -9
查看文件
@@ -116,6 +116,14 @@ function correctionStatusLabel(status: AttendanceCorrectionStatus) {
return "已撤回";
}
function syncRunStatusLabel(status: string) {
if (status === "running") return "同步中";
if (status === "success") return "已完成";
if (status === "skipped") return "已跳过";
if (status === "failed") return "失败";
return "待确认";
}
function dayStatusLabel(status: AttendanceDailyStatus) {
if (status === "normal") return "正常";
if (status === "exception") return "异常";
@@ -158,6 +166,7 @@ export function AttendancePage() {
const { me, permissions } = useAuth();
const canSync = hasPermission(permissions, "attendance.sync");
const canViewAll = hasPermission(permissions, "attendance.view_all");
const canUseOaEmergencyCorrection = hasPermission(permissions, "attendance.manage");
const [activeTab, setActiveTab] = useState<AttendanceTab>("summary");
const [month, setMonth] = useState(currentMonthInput());
const [form, setForm] = useState<CorrectionFormState>(() => buildDefaultForm());
@@ -193,9 +202,9 @@ export function AttendancePage() {
endAt: form.kind !== "missed_punch" ? toIsoOrUndefined(form.endAt) : undefined,
locationSummary: form.kind === "out" ? form.locationSummary.trim() || undefined : undefined,
compensationPolicy: form.kind === "overtime" ? form.compensationPolicy.trim() || undefined : undefined,
}),
}),
onSuccess: async () => {
toast.success("考勤修正已提交审批");
toast.success("考勤应急修正已提交");
setForm(buildDefaultForm());
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["attendance", "unified", "monthly"] }),
@@ -247,7 +256,7 @@ export function AttendancePage() {
if (featureDisabled) {
return (
<PageScaffoldLike>
<PageHeader title="考勤" subtitle="日常打卡在企业微信,审批和解释在 OA。" />
<PageHeader title="考勤" subtitle="企业微信主办打卡、补卡、外出和加班;OA 只保留汇总、解释和审计。" />
<WorkspaceNotice title="企业微信主办" tone="info">
日常打卡、请假和假期余额由企业微信承载;OA 只保留汇总、解释和审计入口。
</WorkspaceNotice>
@@ -262,9 +271,9 @@ export function AttendancePage() {
return (
<PageScaffoldLike>
<PageHeader title="考勤" subtitle="企业微信采集打卡;OA 统一处理请假、补卡、外出、加班和月度解释。" />
<PageHeader title="考勤" subtitle="企业微信主办打卡、补卡、外出和加班;OA 展示月度摘要、异常解释、同步状态和审计记录。" />
<WorkspaceNotice title="企业微信主办" tone="info">
日常打卡仍在企业微信完成;OA 不写回企业微信,只展示汇总、修正审批和同步审计。
日常打卡和考勤类申请仍在企业微信完成。OA 不写回企业微信;只有 HR 或管理员确认应急接管时,才在 OA 记录应急修正。
</WorkspaceNotice>
<div className="grid gap-4 md:grid-cols-4">
@@ -349,7 +358,8 @@ export function AttendancePage() {
</TabsContent>
<TabsContent value="corrections" className="mt-4 grid gap-4 xl:grid-cols-[minmax(0,420px)_minmax(0,1fr)]">
<SectionPanel title="提交修正" description="补卡、外出、加班都在 OA 发起审批;不会写回企业微信。">
{canUseOaEmergencyCorrection ? (
<SectionPanel title="OA 应急修正" description="仅在企业微信不可用且 HR/管理员确认应急接管时使用;不会写回企业微信。">
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
@@ -448,12 +458,19 @@ export function AttendancePage() {
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || !form.reason.trim()}
>
提交审批
提交应急修正
</Button>
</div>
</SectionPanel>
) : (
<SectionPanel title="企微处理入口" description="普通员工不在 OA 里重复发起补卡、外出或加班。">
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-4 text-sm leading-6 text-emerald-800">
请在企业微信提交补卡、外出和加班。OA 当前只展示已同步的解释、历史记录和异常摘要;需要应急接管时由 HR 或管理员处理。
</div>
</SectionPanel>
)}
<SectionPanel title="修正记录" description="审批通过后进入月度考勤解释。">
<SectionPanel title="修正记录" description="展示已留存的解释和应急修正记录,作为月度考勤摘要依据。">
{correctionsQuery.isLoading ? (
<TableSkeleton rows={5} />
) : correctionsQuery.isError ? (
@@ -567,7 +584,7 @@ export function AttendancePage() {
{(syncRunsQuery.data?.items ?? []).map((item) => (
<TableRow key={item.id}>
<TableCell>{item.startDate ?? "-"} ~ {item.endDate ?? "-"}</TableCell>
<TableCell>{item.status}</TableCell>
<TableCell>{syncRunStatusLabel(item.status)}</TableCell>
<TableCell>{item.employeeCount} / {item.recordCount}</TableCell>
<TableCell>{item.errorCode ?? "-"}</TableCell>
<TableCell>{formatDateTime(item.createdAt)}</TableCell>
+79 -58
查看文件
@@ -23,7 +23,7 @@ import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { ListWorkspace, PrimaryTable, StatStrip, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { ListWorkspace, PrimaryTable, StatStrip, WorkspaceHeader, WorkspaceNotice } from "../components/patterns/ListWorkspace";
import { MetricCard } from "../components/patterns/MetricCard";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
@@ -206,6 +206,7 @@ export function LeaveRequestPage() {
const canViewAll = hasLeaveViewAll || hasLeaveManage;
const canViewTeamCalendar = hasLeaveViewAll;
const useDepartmentCalendarScope = hasLeaveViewAll && !hasLeaveManage && Boolean(me?.departmentId);
const canUseOaEmergencyLeave = hasLeaveManage;
const [statusFilter, setStatusFilter] = useState<LeaveRequestStatus | "">("");
const [scope, setScope] = useState<"own" | "all">("own");
@@ -255,6 +256,7 @@ export function LeaveRequestPage() {
const leaveTypesQuery = useQuery({
queryKey: ["leave", "types"],
enabled: canUseOaEmergencyLeave,
queryFn: () => fetchLeaveTypes({ enabled: true }),
});
@@ -416,7 +418,7 @@ export function LeaveRequestPage() {
queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }),
]);
resetForm();
toast.success("请假草稿已创建");
toast.success("OA 应急请假草稿已创建");
},
onError: (error) => {
const message = resolveError(error);
@@ -443,7 +445,7 @@ export function LeaveRequestPage() {
queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }),
]);
resetForm();
toast.success("请假单已保存,可继续提交");
toast.success("OA 应急请假单已保存");
},
onError: (error) => {
const message = resolveError(error);
@@ -460,7 +462,7 @@ export function LeaveRequestPage() {
queryClient.invalidateQueries({ queryKey: ["leave", "calendar"] }),
queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }),
]);
toast.success("请假申请已提交");
toast.success("OA 应急请假单已提交");
},
onError: (error) => {
toast.error(resolveError(error));
@@ -475,7 +477,7 @@ export function LeaveRequestPage() {
queryClient.invalidateQueries({ queryKey: ["leave", "calendar"] }),
queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }),
]);
toast.success("请假申请已撤回");
toast.success("OA 应急请假单已撤回");
setWithdrawTarget(null);
},
onError: (error) => {
@@ -486,15 +488,15 @@ export function LeaveRequestPage() {
const totalPages = Math.max(1, Math.ceil((requestsQuery.data?.total ?? 0) / PAGE_SIZE));
const calendarIsTruncated = (calendarRequestsQuery.data?.total ?? 0) > (calendarRequestsQuery.data?.items.length ?? 0);
const isSavingForm = createMutation.isPending || updateMutation.isPending;
const formTitle = editingLeaveRequest ? "编辑请假单" : "新建请假草稿";
const formTitle = editingLeaveRequest ? "编辑 OA 应急请假单" : "OA 应急请假草稿";
const formDescription = editingLeaveRequest
? "修改原单内容并保留同一请假单 ID,保存后仍可继续提交。"
: "先填写时间段与假种,再补充附件和说明。";
? "仅用于管理员确认企业微信不可用后的应急接管;保存后仍需按应急审批留痕。"
: "日常请假在企业微信发起。这里只保留 HR/管理员应急接管时的 OA 草稿入口。";
const currentAttachmentLabel =
attachmentFile?.name ?? (editingLeaveRequest?.attachment?.originalName ?? null);
const leaveRecordsPanel = (
<SectionPanel title="请假记录" description="支持按状态和范围收敛列表,草稿与驳回单可再次提交。">
<SectionPanel title="请假记录" description="查看企业微信主办或历史 OA 留存的请假状态;日常提交仍从企业微信进入。">
<div className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="text-sm text-zinc-500">最近一次筛选会保留在当前页面状态中。</div>
@@ -560,9 +562,9 @@ export function LeaveRequestPage() {
{(requestsQuery.data?.items ?? []).length ? (
requestsQuery.data?.items.map((item) => {
const isOwner = item.ownerId === me?.id;
const canEdit = isOwner && ["draft", "rejected"].includes(item.status);
const canSubmit = isOwner && ["draft", "rejected"].includes(item.status);
const canWithdraw = isOwner && item.status === "reviewing";
const canEdit = canUseOaEmergencyLeave && isOwner && ["draft", "rejected"].includes(item.status);
const canSubmit = canUseOaEmergencyLeave && isOwner && ["draft", "rejected"].includes(item.status);
const canWithdraw = canUseOaEmergencyLeave && isOwner && item.status === "reviewing";
return (
<TableRow key={item.id}>
@@ -596,39 +598,43 @@ export function LeaveRequestPage() {
</TableCell>
<TableCell>{item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "-"}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="secondary"
type="button"
disabled={!canEdit || isSavingForm}
onClick={() => startEditing(item)}
>
编辑
</Button>
<Button
size="sm"
type="button"
disabled={!canSubmit || submitMutation.isPending}
onClick={() => submitMutation.mutate(item.id)}
>
提交
</Button>
<Button
size="sm"
variant="secondary"
type="button"
disabled={!canWithdraw || withdrawMutation.isPending}
onClick={() =>
setWithdrawTarget({
id: item.id,
label: `${item.leaveType?.name ?? "请假"} · ${formatRangeLabel(item)}`,
})
}
>
撤回
</Button>
</div>
{canUseOaEmergencyLeave ? (
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="secondary"
type="button"
disabled={!canEdit || isSavingForm}
onClick={() => startEditing(item)}
>
编辑
</Button>
<Button
size="sm"
type="button"
disabled={!canSubmit || submitMutation.isPending}
onClick={() => submitMutation.mutate(item.id)}
>
提交应急审批
</Button>
<Button
size="sm"
variant="secondary"
type="button"
disabled={!canWithdraw || withdrawMutation.isPending}
onClick={() =>
setWithdrawTarget({
id: item.id,
label: `${item.leaveType?.name ?? "请假"} · ${formatRangeLabel(item)}`,
})
}
>
撤回
</Button>
</div>
) : (
<span className="text-sm text-zinc-400">企业微信处理</span>
)}
</TableCell>
</TableRow>
);
@@ -780,23 +786,31 @@ export function LeaveRequestPage() {
return (
<ListWorkspace>
<WorkspaceHeader
title="请假申请"
subtitle="支持假种选择、半天粒度请假、额度冻结与撤回释放。"
title="请假记录"
subtitle="企业微信主办请假、补卡、外出和加班;OA 只做记录、额度摘要、团队排班参考和应急留痕。"
/>
<WorkspaceNotice title="企业微信主办" tone="info">
日常请假请在企业微信完成。OA 不重复发起常规请假,只展示同步/历史状态;只有 HR 或管理员确认应急接管时才使用 OA 应急草稿。
</WorkspaceNotice>
<StatStrip columns={4}>
<MetricCard title="可用额度" value={balanceSummary.available.toFixed(1)} note="按当前启用假种汇总" />
<MetricCard title="已冻结" value={balanceSummary.reserved.toFixed(1)} note="审批中申请会占用额度" tone={balanceSummary.reserved > 0 ? "warning" : "neutral"} />
<MetricCard title="已使用" value={balanceSummary.used.toFixed(1)} note="通过后计入已使用" />
<MetricCard
title="本次预估"
value={effectiveRequestedDaysPreview ?? "-"}
note="创建草稿前可先预览天数"
tone={effectiveRequestedDaysPreview ? "warning" : "neutral"}
/>
{canUseOaEmergencyLeave ? (
<MetricCard
title="应急预估"
value={effectiveRequestedDaysPreview ?? "-"}
note="仅 OA 应急草稿使用"
tone={effectiveRequestedDaysPreview ? "warning" : "neutral"}
/>
) : (
<MetricCard title="主办系统" value="企微" note="OA 只读留存和异常说明" tone="neutral" />
)}
</StatStrip>
<div className="page-shell-grid">
{canUseOaEmergencyLeave ? (
<SectionPanel title={formTitle} description={formDescription}>
<div className="space-y-4">
{formError ? (
@@ -813,7 +827,7 @@ export function LeaveRequestPage() {
{isNonWorkingPreview ? (
<p className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-700">
所选日期范围均为非工作日,无需提交请假
所选日期范围均为非工作日,无需发起请假
</p>
) : null}
@@ -957,14 +971,21 @@ export function LeaveRequestPage() {
? updateMutation.isPending
? "保存中..."
: "保存修改"
: createMutation.isPending
? "创建中..."
: "创建草稿"}
: createMutation.isPending
? "创建中..."
: "创建应急草稿"}
</Button>
</div>
</div>
</div>
</SectionPanel>
) : (
<SectionPanel title="日常请假入口" description="普通员工不在 OA 里重复提交请假。">
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-4 text-sm leading-6 text-emerald-800">
请在企业微信完成请假、补卡、外出和加班。OA 当前用于查看记录、额度摘要、团队排班和同步异常;如企业微信不可用,由 HR 或管理员开启应急接管后再使用 OA。
</div>
</SectionPanel>
)}
<SectionPanel title="额度概览" description="假种可用额度、冻结与已使用情况按自然周期汇总。">
<div className="space-y-4">
@@ -1050,7 +1071,7 @@ export function LeaveRequestPage() {
setWithdrawTarget(null);
}
}}
title="确认撤回请假申请?"
title="确认撤回 OA 应急请假单?"
description={withdrawTarget ? `将撤回:${withdrawTarget.label}` : undefined}
confirmLabel="确认撤回"
onConfirm={async () => {
+36 -16
查看文件
@@ -147,7 +147,7 @@ function LeaveRecordCard({
) : null}
{canSubmit ? (
<Button size="sm" type="button" disabled={isSaving} onClick={() => onSubmit(item)}>
提交
提交应急审批
</Button>
) : null}
</div>
@@ -162,7 +162,7 @@ export function MobileLeavePage() {
const { leaveRequestId = "" } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const { permissions } = useAuth();
const canCreateLeave = hasPermission(permissions, "leave.create");
const canUseOaEmergencyLeave = hasPermission(permissions, "leave.manage");
const [view, setView] = useState<LeaveView>(() => resolveLeaveView(searchParams.get("view")));
const [statusFilter, setStatusFilter] = useState<LeaveStatusFilter>(() => {
const queryStatus = searchParams.get("status");
@@ -177,13 +177,14 @@ export function MobileLeavePage() {
const [reason, setReason] = useState("");
const [attachmentFile, setAttachmentFile] = useState<File | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const effectiveView = canUseOaEmergencyLeave ? view : "records";
const currentMobileSource = useMemo(() => {
const params = new URLSearchParams(location.search);
params.set("view", view);
params.set("view", effectiveView);
params.set("status", statusFilter);
const query = params.toString();
return `${location.pathname}${query ? `?${query}` : ""}`;
}, [location.pathname, location.search, statusFilter, view]);
}, [effectiveView, location.pathname, location.search, statusFilter]);
useEffect(() => {
setView(resolveLeaveView(searchParams.get("view")));
@@ -251,7 +252,7 @@ export function MobileLeavePage() {
file: attachmentFile,
}),
onSuccess: async () => {
toast.success("请假草稿已创建");
toast.success("OA 应急请假草稿已创建");
resetForm();
await invalidateLeaveQueries();
},
@@ -276,7 +277,7 @@ export function MobileLeavePage() {
});
},
onSuccess: async () => {
toast.success("请假单已保存");
toast.success("OA 应急请假单已保存");
resetForm();
await invalidateLeaveQueries();
},
@@ -288,7 +289,7 @@ export function MobileLeavePage() {
const submitMutation = useMutation({
mutationFn: (item: LeaveRequestItem) => submitLeaveRequest(item.id),
onSuccess: async () => {
toast.success("请假单已提交");
toast.success("OA 应急请假单已提交");
await invalidateLeaveQueries();
},
onError: (error) => {
@@ -314,6 +315,9 @@ export function MobileLeavePage() {
const isSaving = createMutation.isPending || updateMutation.isPending || submitMutation.isPending;
const startEditing = (item: LeaveRequestItem) => {
if (!canUseOaEmergencyLeave) {
return;
}
setView("form");
const nextParams = new URLSearchParams(searchParams);
nextParams.set("view", "form");
@@ -332,6 +336,9 @@ export function MobileLeavePage() {
function handleViewChange(value: string) {
const nextView = resolveLeaveView(value);
if (nextView === "form" && !canUseOaEmergencyLeave) {
return;
}
setView(nextView);
const nextParams = new URLSearchParams(searchParams);
nextParams.set("view", nextView);
@@ -382,18 +389,30 @@ export function MobileLeavePage() {
return (
<PageScaffold className="space-y-3 pb-4">
<PageHeader title="请假" />
<PageHeader title="请假记录" />
<section className="mobile-card mobile-focus-card">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-base font-semibold text-zinc-900">企业微信主办</div>
<p className="mt-1 text-sm leading-6 text-zinc-600">
日常请假、补卡、外出和加班在企业微信完成;OA 这里只查看记录、额度和历史状态。
</p>
</div>
<span className="badge-success shrink-0">只读</span>
</div>
</section>
<section className="mobile-card">
<Tabs value={view} onValueChange={handleViewChange}>
<TabsList className="grid h-auto w-full grid-cols-2">
<Tabs value={effectiveView} onValueChange={handleViewChange}>
<TabsList className={`grid h-auto w-full ${canUseOaEmergencyLeave ? "grid-cols-2" : "grid-cols-1"}`}>
<TabsTrigger value="records" data-testid="mobile-leave-view-records">请假记录</TabsTrigger>
<TabsTrigger value="form" data-testid="mobile-leave-view-form">新建请假</TabsTrigger>
{canUseOaEmergencyLeave ? <TabsTrigger value="form" data-testid="mobile-leave-view-form">OA 应急</TabsTrigger> : null}
</TabsList>
</Tabs>
</section>
{view === "form" ? (
{effectiveView === "form" ? (
<>
<section className="mobile-card" data-testid="mobile-leave-summary">
<div className="mobile-metric-grid text-sm">
@@ -414,7 +433,7 @@ export function MobileLeavePage() {
<section className="mobile-card" data-testid="mobile-leave-form">
<div className="mb-4 flex items-center justify-between gap-3">
<h3 className="text-base font-semibold text-zinc-900">{editingItem ? "编辑请假" : "新建请假"}</h3>
<h3 className="text-base font-semibold text-zinc-900">{editingItem ? "编辑 OA 应急请假" : "新建 OA 应急请假"}</h3>
{editingItem ? (
<Button variant="secondary" size="sm" type="button" onClick={resetForm}>
取消
@@ -499,7 +518,7 @@ export function MobileLeavePage() {
<Button
className="min-h-12 w-full"
type="button"
disabled={!canCreateLeave || isSaving || !leaveTypeId || !startDate || !endDate}
disabled={!canUseOaEmergencyLeave || isSaving || !leaveTypeId || !startDate || !endDate}
onClick={() => {
if (editingItem) {
updateMutation.mutate();
@@ -509,7 +528,7 @@ export function MobileLeavePage() {
}}
data-testid="mobile-leave-save"
>
{editingItem ? "保存修改" : "创建草稿"}
{editingItem ? "保存修改" : "创建应急草稿"}
</Button>
</div>
</section>
@@ -546,7 +565,7 @@ export function MobileLeavePage() {
</>
) : null}
{view === "records" ? (
{effectiveView === "records" ? (
<section className="mobile-card" data-testid="mobile-leave-records">
<h3 className="text-base font-semibold text-zinc-900">请假记录</h3>
<Tabs value={statusFilter} onValueChange={handleStatusChange}>
@@ -578,6 +597,7 @@ export function MobileLeavePage() {
key={item.id}
onEdit={startEditing}
onSubmit={(nextItem) => submitMutation.mutate(nextItem)}
showActions={canUseOaEmergencyLeave}
/>
))}
</div>