ATTV1 实现考勤统一入口与企微同步
这个提交包含在:
@@ -2,12 +2,6 @@ import { useEffect } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
clockInAttendance,
|
||||
clockOutAttendance,
|
||||
fetchMyTodayAttendance,
|
||||
type AttendanceTodayResponse,
|
||||
} from "../api/attendance";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { fetchNotifications, markNotificationRead, type NotificationItem } from "../api/notifications";
|
||||
import { fetchPortalHome } from "../api/portal";
|
||||
@@ -49,10 +43,6 @@ function resolveErrorMessage(error: unknown) {
|
||||
return "请求失败,请稍后重试。";
|
||||
}
|
||||
|
||||
function isAttendanceFeatureDisabled(error: unknown) {
|
||||
return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_v1_disabled";
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
@@ -70,30 +60,6 @@ function formatDateTime(value: string | null) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatShortTime(value: string | null) {
|
||||
if (!value) {
|
||||
return "--:--";
|
||||
}
|
||||
try {
|
||||
return new Date(value).toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
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 notificationTypeLabel(type: string) {
|
||||
if (type.startsWith("workflow.")) return "审批事件";
|
||||
if (type.startsWith("reimbursement.")) return "报销事件";
|
||||
@@ -104,31 +70,6 @@ function notificationTypeLabel(type: string) {
|
||||
return "系统事件";
|
||||
}
|
||||
|
||||
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 resolveAttendanceAction(attendanceToday: AttendanceTodayResponse | null) {
|
||||
if (!attendanceToday) {
|
||||
return null;
|
||||
}
|
||||
if (!attendanceToday.needClockIn) {
|
||||
return null;
|
||||
}
|
||||
if (!attendanceToday.item?.clockInAt) {
|
||||
return "clock-in" as const;
|
||||
}
|
||||
if (!attendanceToday.item.clockOutAt) {
|
||||
return "clock-out" as const;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ActionTile({ action }: { action: MobileAction }) {
|
||||
const isPrimary = action.tone === "primary";
|
||||
const isWarning = action.tone === "warning";
|
||||
@@ -176,12 +117,6 @@ export function MobilePortalPage() {
|
||||
const canCreateExpense = hasAnyPermission(permissions, ["expense.create"]);
|
||||
const currentMobileSource = `${location.pathname}${location.search}`;
|
||||
|
||||
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-items"],
|
||||
@@ -229,39 +164,6 @@ 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 attendanceToday = attendanceTodayQuery.data ?? null;
|
||||
const attendanceAction = resolveAttendanceAction(attendanceToday);
|
||||
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
|
||||
const pendingApprovalCount = canAccessApprovals
|
||||
? workflowSummaryQuery.data?.pendingTotal ?? pendingCountQuery.data?.total ?? 0
|
||||
: 0;
|
||||
@@ -320,6 +222,13 @@ export function MobilePortalPage() {
|
||||
to: "/mobile/leave?view=form",
|
||||
}
|
||||
: null,
|
||||
{
|
||||
key: "attendance",
|
||||
label: "考勤",
|
||||
meta: "汇总修正",
|
||||
icon: "schedule",
|
||||
to: "/attendance",
|
||||
},
|
||||
canAccessPurchase
|
||||
? {
|
||||
key: "purchase",
|
||||
@@ -443,50 +352,18 @@ export function MobilePortalPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!attendanceFeatureDisabled ? (
|
||||
<section className="mobile-card mobile-page-stack" data-testid="mobile-attendance-card">
|
||||
<div className="mobile-section-header">
|
||||
<div className="mobile-section-title">打卡</div>
|
||||
<span className="badge-neutral">{attendanceToday?.workDate ?? "今日"}</span>
|
||||
</div>
|
||||
|
||||
{attendanceTodayQuery.isLoading ? (
|
||||
<p className="text-sm text-zinc-500">读取中...</p>
|
||||
) : attendanceTodayQuery.isError ? (
|
||||
<p className="rounded-lg 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="text-sm text-zinc-600">{attendanceExemptionLabel(attendanceToday.exemptionReason)}</div>
|
||||
) : attendanceToday?.item?.clockInAt && attendanceToday.item.clockOutAt ? (
|
||||
<div className="text-sm text-zinc-600">
|
||||
{formatShortTime(attendanceToday.item.clockInAt)} - {formatShortTime(attendanceToday.item.clockOutAt)}
|
||||
<span className="ml-2 text-zinc-400">工时 {formatWorkMinutes(attendanceToday.item.workMinutes)}</span>
|
||||
</div>
|
||||
) : attendanceToday?.item?.clockInAt ? (
|
||||
<div className="text-sm text-zinc-600">已上班 {formatShortTime(attendanceToday.item.clockInAt)}</div>
|
||||
) : (
|
||||
<div className="text-sm text-zinc-600">今天还没打卡</div>
|
||||
)}
|
||||
|
||||
{attendanceAction ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-3 w-full"
|
||||
disabled={attendanceClockInMutation.isPending || attendanceClockOutMutation.isPending}
|
||||
onClick={() => {
|
||||
if (attendanceAction === "clock-in") {
|
||||
attendanceClockInMutation.mutate();
|
||||
} else {
|
||||
attendanceClockOutMutation.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{attendanceAction === "clock-in" ? "上班打卡" : "下班打卡"}
|
||||
</Button>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
<section className="mobile-card mobile-page-stack" data-testid="mobile-attendance-summary-card">
|
||||
<div className="mobile-section-header">
|
||||
<div className="mobile-section-title">考勤</div>
|
||||
<span className="badge-neutral">企业微信打卡</span>
|
||||
</div>
|
||||
<div className="text-sm leading-6 text-zinc-600">
|
||||
日常打卡在企业微信完成;补卡、外出、加班和月度解释在 OA 处理。
|
||||
</div>
|
||||
<Button asChild className="mt-3 w-full" size="sm" type="button" variant="secondary">
|
||||
<Link to="/attendance">查看考勤汇总</Link>
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section className="mobile-card mobile-page-stack" data-testid="mobile-notifications">
|
||||
<div className="mobile-section-header">
|
||||
|
||||
在新工单中引用
屏蔽一个用户