R59 A1-A6:极简考勤打卡
- 新增 AttendanceRecord 与 AttendancePolicy 模型 - 上下班打卡、今日状态、月度统计端点 - 考勤管理页面(我的/团队/设置三 Tab) - 移动端快速打卡入口 - 管理员补卡与审计日志 - 单测覆盖打卡幂等、迟到计算、工时计算、权限边界
这个提交包含在:
@@ -1,6 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
clockInAttendance,
|
||||
clockOutAttendance,
|
||||
fetchMyTodayAttendance,
|
||||
} from "../api/attendance";
|
||||
import { fetchNotifications } from "../api/notifications";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { isApiError } from "../api/types";
|
||||
@@ -36,7 +42,10 @@ function resolveErrorMessage(error: unknown) {
|
||||
return "请求失败,请稍后重试。";
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
function formatDateTime(value: string | null) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
try {
|
||||
return new Date(value).toLocaleString("zh-CN");
|
||||
} catch {
|
||||
@@ -69,6 +78,28 @@ function canQuickUpdatePresence(item: PresenceItem | null) {
|
||||
return !(item?.status === "leave" && item.source === "leave_sync");
|
||||
}
|
||||
|
||||
function isAttendanceFeatureDisabled(error: unknown) {
|
||||
return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_v1_disabled";
|
||||
}
|
||||
|
||||
function attendanceExemptionLabel(reason: unknown) {
|
||||
if (reason === "leave") return "今日请假,无需打卡";
|
||||
if (reason === "travel") return "今日差旅,无需打卡";
|
||||
if (reason === "holiday") return "今日节假日,无需打卡";
|
||||
if (reason === "weekend") return "今日周末,无需打卡";
|
||||
if (reason === "policy_disabled") return "当前考勤策略未启用";
|
||||
return "今日无需打卡";
|
||||
}
|
||||
|
||||
function formatWorkMinutes(value: number | null | undefined) {
|
||||
if (value == null) {
|
||||
return "-";
|
||||
}
|
||||
const hours = Math.floor(value / 60);
|
||||
const minutes = value % 60;
|
||||
return `${hours}h ${String(minutes).padStart(2, "0")}m`;
|
||||
}
|
||||
|
||||
function resolveNotificationTargetPath(
|
||||
item: { targetType: string | null; targetId: string | null },
|
||||
canAccessApprovals: boolean,
|
||||
@@ -123,17 +154,33 @@ function QuickLinkCard({
|
||||
export function MobilePortalPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions } = useAuth();
|
||||
const [nowLabel, setNowLabel] = useState(() =>
|
||||
new Date().toLocaleTimeString("zh-CN", { hour12: false }),
|
||||
);
|
||||
const canAccessApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]);
|
||||
const canAccessCapture = hasAnyPermission(permissions, [...MOBILE_CAPTURE_PERMISSIONS]);
|
||||
const canAccessAssetScan = hasAnyPermission(permissions, [...MOBILE_ASSET_SCAN_PERMISSIONS]);
|
||||
const canAccessLeave = hasAnyPermission(permissions, [...MOBILE_LEAVE_PERMISSIONS]);
|
||||
const canAccessReimbursements = hasAnyPermission(permissions, [...MOBILE_REIMBURSEMENT_PERMISSIONS]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
setNowLabel(new Date().toLocaleTimeString("zh-CN", { hour12: false }));
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const presenceQuery = useQuery({
|
||||
queryKey: ["mobile", "portal", "presence"],
|
||||
queryFn: () => fetchMyPresence(),
|
||||
});
|
||||
|
||||
const attendanceTodayQuery = useQuery({
|
||||
queryKey: ["mobile", "portal", "attendance", "today"],
|
||||
queryFn: () => fetchMyTodayAttendance(),
|
||||
retry: (failureCount, error) => !isAttendanceFeatureDisabled(error) && failureCount < 2,
|
||||
});
|
||||
|
||||
const pendingCountQuery = useQuery({
|
||||
enabled: canAccessApprovals,
|
||||
queryKey: ["mobile", "workflow", "pending-count"],
|
||||
@@ -165,7 +212,43 @@ export function MobilePortalPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const attendanceClockInMutation = useMutation({
|
||||
mutationFn: () => clockInAttendance(),
|
||||
onSuccess: async (response) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["mobile", "portal", "attendance", "today"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
|
||||
]);
|
||||
toast.success(
|
||||
response.item.isLate
|
||||
? `上班打卡成功,已迟到 ${response.item.lateMinutes} 分钟`
|
||||
: "上班打卡成功",
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
const attendanceClockOutMutation = useMutation({
|
||||
mutationFn: () => clockOutAttendance(),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["mobile", "portal", "attendance", "today"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
|
||||
]);
|
||||
toast.success("下班打卡成功");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
const presenceItem = presenceQuery.data?.item ?? null;
|
||||
const attendanceToday = attendanceTodayQuery.data ?? null;
|
||||
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
|
||||
const notifications = notificationsQuery.data?.items ?? [];
|
||||
|
||||
return (
|
||||
@@ -175,6 +258,81 @@ export function MobilePortalPage() {
|
||||
subtitle="处理今日考勤、审批和高频入口。"
|
||||
/>
|
||||
|
||||
{!attendanceFeatureDisabled ? (
|
||||
<section className="card-std space-y-4 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-zinc-900">今日考勤</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{attendanceToday?.workDate ?? "今日"} · 当前时间 {nowLabel}
|
||||
</div>
|
||||
</div>
|
||||
<span className="badge-neutral">{attendanceToday?.workDate ?? "今日"}</span>
|
||||
</div>
|
||||
|
||||
{attendanceTodayQuery.isLoading ? (
|
||||
<p className="text-sm text-zinc-500">加载今日考勤中...</p>
|
||||
) : attendanceTodayQuery.isError ? (
|
||||
<p className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveErrorMessage(attendanceTodayQuery.error)}
|
||||
</p>
|
||||
) : attendanceToday && !attendanceToday.needClockIn ? (
|
||||
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4">
|
||||
<div className="text-base font-semibold text-zinc-900">{attendanceExemptionLabel(attendanceToday.exemptionReason)}</div>
|
||||
<div className="mt-2 text-sm text-zinc-500">
|
||||
{attendanceToday.item?.clockInAt || attendanceToday.item?.clockOutAt
|
||||
? `已有补录记录:${formatDateTime(attendanceToday.item.clockInAt)} / ${formatDateTime(attendanceToday.item.clockOutAt)}`
|
||||
: "系统已自动排除缺卡统计。"}
|
||||
</div>
|
||||
</div>
|
||||
) : attendanceToday?.item?.clockInAt && !attendanceToday.item.clockOutAt ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-4 text-sm text-amber-700">
|
||||
已打上班卡:{formatDateTime(attendanceToday.item.clockInAt)}
|
||||
{attendanceToday.item.isLate ? `,迟到 ${attendanceToday.item.lateMinutes} 分钟` : ""}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="w-full bg-amber-500 text-white hover:bg-amber-600"
|
||||
disabled={attendanceClockOutMutation.isPending}
|
||||
onClick={() => attendanceClockOutMutation.mutate()}
|
||||
>
|
||||
下班打卡
|
||||
</Button>
|
||||
</div>
|
||||
) : attendanceToday?.item?.clockInAt && attendanceToday.item.clockOutAt ? (
|
||||
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4">
|
||||
<div className="text-base font-semibold text-zinc-900">今日已完成</div>
|
||||
<div className="mt-2 text-sm text-zinc-600">
|
||||
上班:{formatDateTime(attendanceToday.item.clockInAt)}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-zinc-600">
|
||||
下班:{formatDateTime(attendanceToday.item.clockOutAt)}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-zinc-600">
|
||||
工时:{formatWorkMinutes(attendanceToday.item.workMinutes)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-4 text-sm text-emerald-700">
|
||||
今日尚未打上班卡。
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="w-full bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
disabled={attendanceClockInMutation.isPending}
|
||||
onClick={() => attendanceClockInMutation.mutate()}
|
||||
>
|
||||
上班打卡
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="card-std space-y-4 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
|
||||
在新工单中引用
屏蔽一个用户