ATTV1 实现考勤统一入口与企微同步
这个提交包含在:
+11
-195
@@ -1,13 +1,6 @@
|
||||
import { type ReactNode, 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,
|
||||
type AttendanceTodayResponse,
|
||||
} from "../api/attendance";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import {
|
||||
fetchPortalHome,
|
||||
@@ -141,20 +134,6 @@ function formatWeeklyStatus(payload: PortalHomePayload["weeklyReport"]) {
|
||||
return payload.status === "submitted" ? "已提交" : "待填写";
|
||||
}
|
||||
|
||||
function resolveErrorMessage(error: unknown) {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return "请求失败,请稍后重试。";
|
||||
}
|
||||
|
||||
function isAttendanceFeatureDisabled(error: unknown) {
|
||||
return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_v1_disabled";
|
||||
}
|
||||
|
||||
function formatRelativeTime(value: string | null) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
@@ -184,30 +163,6 @@ function formatRelativeTime(value: string | null) {
|
||||
return `${diffDays} 天前`;
|
||||
}
|
||||
|
||||
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 buildTodoAggregateItems(payload: PortalHomePayload) {
|
||||
const cards = Array.isArray(payload.cards) ? payload.cards : [];
|
||||
const items: TodoAggregateItem[] = [];
|
||||
@@ -283,11 +238,8 @@ function resolveTeamStatusBadge(item: PortalTeamStatusItem) {
|
||||
if (item.presenceStatus === "remote") {
|
||||
return { label: "居家", className: "badge-neutral" };
|
||||
}
|
||||
if (!item.attendanceClockIn) {
|
||||
return { label: "未打卡", className: "badge-neutral" };
|
||||
}
|
||||
return {
|
||||
label: "已打卡",
|
||||
label: "考勤",
|
||||
className: "inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-slate-700 ring-1 ring-inset ring-slate-600/10",
|
||||
};
|
||||
}
|
||||
@@ -296,12 +248,6 @@ function formatTeamStatusAttendance(item: PortalTeamStatusItem) {
|
||||
if (item.onLeave) {
|
||||
return item.leaveType ? `${item.leaveType} 已审批` : "今日请假";
|
||||
}
|
||||
if (item.attendanceClockIn && item.attendanceClockOut) {
|
||||
return `${formatShortTime(item.attendanceClockIn)} - ${formatShortTime(item.attendanceClockOut)}`;
|
||||
}
|
||||
if (item.attendanceClockIn) {
|
||||
return `${formatShortTime(item.attendanceClockIn)} 已打卡`;
|
||||
}
|
||||
if (item.presenceStatus === "travel") {
|
||||
return "今日差旅";
|
||||
}
|
||||
@@ -311,80 +257,7 @@ function formatTeamStatusAttendance(item: PortalTeamStatusItem) {
|
||||
if (item.presenceStatus === "remote") {
|
||||
return "今日居家办公";
|
||||
}
|
||||
return "暂无打卡记录";
|
||||
}
|
||||
|
||||
function resolveClockAction(
|
||||
attendanceToday: AttendanceTodayResponse | null,
|
||||
state: {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
featureDisabled: boolean;
|
||||
pending: boolean;
|
||||
},
|
||||
) {
|
||||
if (state.pending) {
|
||||
return {
|
||||
label: "处理中...",
|
||||
description: "正在提交考勤动作",
|
||||
disabled: true,
|
||||
action: null,
|
||||
} as const;
|
||||
}
|
||||
if (state.isLoading) {
|
||||
return {
|
||||
label: "打卡读取中",
|
||||
description: "正在读取今日考勤状态",
|
||||
disabled: true,
|
||||
action: null,
|
||||
} as const;
|
||||
}
|
||||
if (state.isError && !state.featureDisabled) {
|
||||
return {
|
||||
label: "打卡不可用",
|
||||
description: "当前无法读取今日考勤状态",
|
||||
disabled: true,
|
||||
action: null,
|
||||
} as const;
|
||||
}
|
||||
if (!attendanceToday) {
|
||||
return {
|
||||
label: "今日考勤",
|
||||
description: "暂无可用考勤数据",
|
||||
disabled: true,
|
||||
action: null,
|
||||
} as const;
|
||||
}
|
||||
if (!attendanceToday.needClockIn) {
|
||||
return {
|
||||
label: "今日无需打卡",
|
||||
description: "系统已自动排除缺卡统计",
|
||||
disabled: true,
|
||||
action: null,
|
||||
} as const;
|
||||
}
|
||||
if (!attendanceToday.item?.clockInAt) {
|
||||
return {
|
||||
label: "上班打卡",
|
||||
description: "记录今日到岗时间",
|
||||
disabled: false,
|
||||
action: "clock-in",
|
||||
} as const;
|
||||
}
|
||||
if (!attendanceToday.item.clockOutAt) {
|
||||
return {
|
||||
label: "下班打卡",
|
||||
description: `已上班 ${formatShortTime(attendanceToday.item.clockInAt)}`,
|
||||
disabled: false,
|
||||
action: "clock-out",
|
||||
} as const;
|
||||
}
|
||||
return {
|
||||
label: "今日已完成",
|
||||
description: `工时 ${formatWorkMinutes(attendanceToday.item.workMinutes)}`,
|
||||
disabled: true,
|
||||
action: null,
|
||||
} as const;
|
||||
return "企微考勤见考勤页";
|
||||
}
|
||||
|
||||
function SummaryPanel({ title, items }: { title: string; items: SummaryItem[] }) {
|
||||
@@ -921,16 +794,10 @@ function ActivityAnnouncementsPanel({
|
||||
}
|
||||
|
||||
export function PortalHomePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { me, permissions } = useAuth();
|
||||
const [showWelcome, setShowWelcome] = useState(false);
|
||||
const [showGettingStarted, setShowGettingStarted] = useState(false);
|
||||
const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome() });
|
||||
const attendanceTodayQuery = useQuery({
|
||||
queryKey: ["portal-home", "attendance", "today"],
|
||||
queryFn: () => fetchMyTodayAttendance(),
|
||||
retry: (failureCount, error) => !isAttendanceFeatureDisabled(error) && failureCount < 2,
|
||||
});
|
||||
const workflowSummaryQuery = useQuery({
|
||||
enabled: hasAnyPermission(permissions, APPROVAL_PORTAL_PERMISSIONS),
|
||||
queryKey: ["workflow", "tasks", "summary", "mine"],
|
||||
@@ -980,42 +847,6 @@ export function PortalHomePage() {
|
||||
];
|
||||
const gettingStartedCompletedCount = gettingStartedItems.filter((item) => item.completed).length;
|
||||
|
||||
const attendanceClockInMutation = useMutation({
|
||||
mutationFn: () => clockInAttendance(),
|
||||
onSuccess: async (response) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home", "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: ["portal-home"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home", "attendance", "today"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
|
||||
]);
|
||||
toast.success("下班打卡成功");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
if (portalQuery.isLoading) {
|
||||
return <div className="card-std p-6 text-sm text-zinc-500">加载 Portal 中...</div>;
|
||||
}
|
||||
@@ -1052,14 +883,6 @@ export function PortalHomePage() {
|
||||
const primaryCards = pickCards(cards, portalSchema.heroCards);
|
||||
const primaryCardKeySet = primaryCards.map((card) => card.key);
|
||||
const secondaryCards = compactCards(cards, primaryCardKeySet);
|
||||
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
|
||||
const attendanceToday = attendanceTodayQuery.data ?? null;
|
||||
const clockAction = resolveClockAction(attendanceToday, {
|
||||
isLoading: attendanceTodayQuery.isLoading,
|
||||
isError: attendanceTodayQuery.isError,
|
||||
featureDisabled: attendanceFeatureDisabled,
|
||||
pending: attendanceClockInMutation.isPending || attendanceClockOutMutation.isPending,
|
||||
});
|
||||
const todoAggregateItems = buildTodoAggregateItems({ ...payload, cards });
|
||||
|
||||
function handleCloseWelcome() {
|
||||
@@ -1222,20 +1045,13 @@ export function PortalHomePage() {
|
||||
to: "/reimbursements",
|
||||
});
|
||||
}
|
||||
if (!attendanceFeatureDisabled) {
|
||||
quickActions.push({
|
||||
key: "attendance",
|
||||
icon: clockAction.action === "clock-out" ? "logout" : "schedule",
|
||||
label: clockAction.label,
|
||||
description: clockAction.description,
|
||||
disabled: clockAction.disabled,
|
||||
onClick: clockAction.action === "clock-in"
|
||||
? () => attendanceClockInMutation.mutate()
|
||||
: clockAction.action === "clock-out"
|
||||
? () => attendanceClockOutMutation.mutate()
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
quickActions.push({
|
||||
key: "attendance",
|
||||
icon: "schedule",
|
||||
label: "考勤汇总",
|
||||
description: "查看企微打卡摘要和 OA 修正",
|
||||
to: "/attendance",
|
||||
});
|
||||
if (payload.weeklyReport || payload.weeklySummary) {
|
||||
quickActions.push({
|
||||
key: "weekly",
|
||||
@@ -1262,7 +1078,7 @@ export function PortalHomePage() {
|
||||
? "配置、审计、通知。"
|
||||
: isManagerView
|
||||
? "审批、团队、周报。"
|
||||
: "待办、打卡、申请。"}
|
||||
: "待办、申请、考勤。"}
|
||||
actions={
|
||||
!payload.workspace.persisted && canSetupWorkspace ? (
|
||||
<Button asChild variant="secondary" type="button">
|
||||
|
||||
在新工单中引用
屏蔽一个用户