R59 A1-A6:极简考勤打卡
- 新增 AttendanceRecord 与 AttendancePolicy 模型 - 上下班打卡、今日状态、月度统计端点 - 考勤管理页面(我的/团队/设置三 Tab) - 移动端快速打卡入口 - 管理员补卡与审计日志 - 单测覆盖打卡幂等、迟到计算、工时计算、权限边界
这个提交包含在:
@@ -0,0 +1,168 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type AttendanceExemptionReason =
|
||||
| "leave"
|
||||
| "travel"
|
||||
| "holiday"
|
||||
| "weekend"
|
||||
| "policy_disabled";
|
||||
|
||||
export type AttendanceStatus =
|
||||
| "normal"
|
||||
| "late"
|
||||
| "early_leave"
|
||||
| "late_early_leave"
|
||||
| "pending_clock_out"
|
||||
| "missed"
|
||||
| "leave"
|
||||
| "travel"
|
||||
| "holiday"
|
||||
| "weekend";
|
||||
|
||||
export type AttendanceRecordItem = {
|
||||
id: string | null;
|
||||
employeeId: string;
|
||||
workDate: string;
|
||||
clockInAt: string | null;
|
||||
clockOutAt: string | null;
|
||||
workMinutes: number | null;
|
||||
isLate: boolean;
|
||||
lateMinutes: number;
|
||||
isEarlyLeave: boolean;
|
||||
earlyMinutes: number;
|
||||
note: string | null;
|
||||
source: "web" | "mobile" | "admin" | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type AttendanceTodayResponse = {
|
||||
workDate: string;
|
||||
needClockIn: boolean;
|
||||
exemptionReason: AttendanceExemptionReason | null;
|
||||
item: AttendanceRecordItem | null;
|
||||
};
|
||||
|
||||
export type AttendanceMonthItem = AttendanceRecordItem & {
|
||||
attendanceStatus: AttendanceStatus;
|
||||
needClockIn: boolean;
|
||||
exemptionReason: AttendanceExemptionReason | null;
|
||||
};
|
||||
|
||||
export type AttendanceMonthSummary = {
|
||||
totalWorkDays: number;
|
||||
clockedDays: number;
|
||||
lateDays: number;
|
||||
earlyLeaveDays: number;
|
||||
missedDays: number;
|
||||
totalWorkMinutes: number;
|
||||
};
|
||||
|
||||
export type AttendanceMonthResponse = {
|
||||
year: number;
|
||||
month: number;
|
||||
summary: AttendanceMonthSummary;
|
||||
items: AttendanceMonthItem[];
|
||||
};
|
||||
|
||||
export type AttendanceTeamItem = {
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
employeeNo: string;
|
||||
title: string;
|
||||
departmentId: string | null;
|
||||
departmentName: string | null;
|
||||
attendanceStatus: AttendanceStatus;
|
||||
needClockIn: boolean;
|
||||
exemptionReason: AttendanceExemptionReason | null;
|
||||
clockInAt: string | null;
|
||||
clockOutAt: string | null;
|
||||
workMinutes: number | null;
|
||||
note: string | null;
|
||||
source: "web" | "mobile" | "admin" | null;
|
||||
};
|
||||
|
||||
export type AttendancePolicy = {
|
||||
id: string | null;
|
||||
workStartTime: string;
|
||||
workEndTime: string;
|
||||
lunchStartTime: string;
|
||||
lunchEndTime: string;
|
||||
lateToleranceMinutes: number;
|
||||
enabled: boolean;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
updatedBy: string | null;
|
||||
};
|
||||
|
||||
export async function clockInAttendance(note?: string | null) {
|
||||
return apiRequestData<{ item: AttendanceRecordItem }>("/api/attendance/clock-in", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(note ? { note } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function clockOutAttendance(note?: string | null) {
|
||||
return apiRequestData<{ item: AttendanceRecordItem }>("/api/attendance/clock-out", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(note ? { note } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchMyTodayAttendance() {
|
||||
return apiRequestData<AttendanceTodayResponse>("/api/attendance/me/today");
|
||||
}
|
||||
|
||||
export async function fetchMyMonthAttendance(params?: { year?: number; month?: number }) {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.year) {
|
||||
search.set("year", String(params.year));
|
||||
}
|
||||
if (params?.month) {
|
||||
search.set("month", String(params.month));
|
||||
}
|
||||
const query = search.toString();
|
||||
return apiRequestData<AttendanceMonthResponse>(`/api/attendance/me/month${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export async function fetchTeamAttendance(params?: { date?: string; departmentId?: string }) {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.date) {
|
||||
search.set("date", params.date);
|
||||
}
|
||||
if (params?.departmentId) {
|
||||
search.set("departmentId", params.departmentId);
|
||||
}
|
||||
const query = search.toString();
|
||||
return apiRequestData<{ date: string; items: AttendanceTeamItem[] }>(`/api/attendance/team${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export async function fetchAttendancePolicy() {
|
||||
return apiRequestData<{ item: AttendancePolicy }>("/api/attendance/policy");
|
||||
}
|
||||
|
||||
export async function updateAttendancePolicy(payload: {
|
||||
workStartTime: string;
|
||||
workEndTime: string;
|
||||
lunchStartTime: string;
|
||||
lunchEndTime: string;
|
||||
lateToleranceMinutes: number;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
return apiRequestData<{ item: AttendancePolicy }>("/api/attendance/policy", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function adminBackfillAttendance(payload: {
|
||||
employeeId: string;
|
||||
workDate: string;
|
||||
clockInAt?: string | null;
|
||||
clockOutAt?: string | null;
|
||||
note?: string | null;
|
||||
}) {
|
||||
return apiRequestData<{ item: AttendanceRecordItem }>("/api/attendance/admin/backfill", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,7 @@ const FEATURE_FLAG_DISABLED_MESSAGES: Record<string, string> = {
|
||||
portal_home_v1_disabled: "Portal 首页当前未启用,请联系管理员检查环境开关与功能开关配置。",
|
||||
announcements_v1_disabled: "公告模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
|
||||
presence_board_v1_disabled: "团队状态模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
|
||||
attendance_v1_disabled: "考勤模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
|
||||
weekly_report_v1_disabled: "周报模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
|
||||
employee_lifecycle_v1_disabled: "人员生命周期模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
|
||||
todo_board_v1_disabled: "待办模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
|
||||
|
||||
@@ -13,6 +13,7 @@ export type FeatureFlagKey =
|
||||
| "workflow_definition_admin_v1"
|
||||
| "cutover_console_v1"
|
||||
| "presence_board_v1"
|
||||
| "attendance_v1"
|
||||
| "weekly_report_v1"
|
||||
| "employee_lifecycle_v1"
|
||||
| "todo_board_v1"
|
||||
|
||||
@@ -21,6 +21,7 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
{ to: "/portal", label: "Portal", icon: "home_app_logo" },
|
||||
{ to: "/team", label: "团队", icon: "groups" },
|
||||
{ to: "/leave-requests", label: "请假", icon: "event_available", anyOf: ["leave.view_own", "leave.view_all", "leave.manage"] },
|
||||
{ to: "/attendance", label: "考勤", icon: "schedule" },
|
||||
{ to: "/reimbursements", label: "报销", icon: "request_quote", anyOf: ["expense.view_own", "expense.view_all"] },
|
||||
{ to: "/assets", label: "资产", icon: "inventory_2", anyOf: ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"] },
|
||||
{ to: "/weekly-reports", label: "周报", icon: "article" },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const MAIN_NAV_ORDER = ["/portal", "/team", "/leave-requests", "/reimbursements", "/assets", "/weekly-reports", "/todos", "/announcements", "/projects"];
|
||||
export const MAIN_NAV_ORDER = ["/portal", "/team", "/leave-requests", "/attendance", "/reimbursements", "/assets", "/weekly-reports", "/todos", "/announcements", "/projects"];
|
||||
export const ADMIN_NAV_ORDER = ["/system/workspace", "/people", "/organization", "/system/workflow-definitions", "/system/settings", "/system/feature-flags", "/audit-logs"];
|
||||
export const BACKUP_NAV_ORDER = ["/workflow/tasks", "/contracts-registry", "/purchase-requests", "/travel-requests", "/budgets", "/payment-batches", "/ap-operations", "/invoice-pool", "/inventory-checks", "/system/cutover", "/system/integrations/erp", "/dashboard", "/settings/profile"];
|
||||
|
||||
@@ -6,6 +6,7 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
||||
{ path: "/portal", title: "Portal", group: "工作台" },
|
||||
{ path: "/announcements", title: "公告", group: "主导航" },
|
||||
{ path: "/team", title: "团队", group: "主导航" },
|
||||
{ path: "/attendance", title: "考勤", group: "主导航" },
|
||||
{ path: "/projects", title: "项目", group: "主导航" },
|
||||
{ path: "/contracts-registry", title: "合同台账", group: "备用模块" },
|
||||
{ path: "/weekly-reports", title: "周报", group: "主导航" },
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { fetchDepartments } from "../api/departments";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import {
|
||||
adminBackfillAttendance,
|
||||
clockInAttendance,
|
||||
clockOutAttendance,
|
||||
fetchAttendancePolicy,
|
||||
fetchMyMonthAttendance,
|
||||
fetchMyTodayAttendance,
|
||||
fetchTeamAttendance,
|
||||
updateAttendancePolicy,
|
||||
type AttendanceMonthItem,
|
||||
type AttendancePolicy,
|
||||
type AttendanceStatus,
|
||||
type AttendanceTeamItem,
|
||||
} from "../api/attendance";
|
||||
import { isApiError } from "../api/types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import { MetricCard } from "../components/patterns/MetricCard";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../components/ui/dialog";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
|
||||
type AttendanceTab = "mine" | "team" | "settings";
|
||||
type PolicyFormState = {
|
||||
workStartTime: string;
|
||||
workEndTime: string;
|
||||
lunchStartTime: string;
|
||||
lunchEndTime: string;
|
||||
lateToleranceMinutes: string;
|
||||
};
|
||||
|
||||
type BackfillState = {
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
workDate: string;
|
||||
clockInAt: string;
|
||||
clockOutAt: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
function currentDateInput() {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function currentMonthInput() {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function toPolicyForm(policy?: AttendancePolicy | null): PolicyFormState {
|
||||
return {
|
||||
workStartTime: policy?.workStartTime ?? "09:00",
|
||||
workEndTime: policy?.workEndTime ?? "18:00",
|
||||
lunchStartTime: policy?.lunchStartTime ?? "12:00",
|
||||
lunchEndTime: policy?.lunchEndTime ?? "13:00",
|
||||
lateToleranceMinutes: String(policy?.lateToleranceMinutes ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveErrorMessage(error: unknown, fallback = "请求失败,请稍后重试") {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function isAttendanceDisabledError(error: unknown) {
|
||||
return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_v1_disabled";
|
||||
}
|
||||
|
||||
function formatDateTimeCompact(value: string | null) {
|
||||
if (!value) return "-";
|
||||
return new Date(value).toLocaleTimeString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function formatMinutes(value: number | null) {
|
||||
if (value == null) return "-";
|
||||
const hours = Math.floor(value / 60);
|
||||
const minutes = value % 60;
|
||||
return `${hours}h ${String(minutes).padStart(2, "0")}m`;
|
||||
}
|
||||
|
||||
function attendanceStatusLabel(status: AttendanceStatus) {
|
||||
if (status === "normal") return "正常";
|
||||
if (status === "late") return "迟到";
|
||||
if (status === "early_leave") return "早退";
|
||||
if (status === "late_early_leave") return "迟到/早退";
|
||||
if (status === "pending_clock_out") return "待下班卡";
|
||||
if (status === "missed") return "缺卡";
|
||||
if (status === "leave") return "请假";
|
||||
if (status === "travel") return "差旅";
|
||||
if (status === "holiday") return "节假日";
|
||||
return "周末";
|
||||
}
|
||||
|
||||
function attendanceStatusClass(status: AttendanceStatus) {
|
||||
if (status === "late" || status === "early_leave" || status === "late_early_leave") {
|
||||
return "border-amber-200 bg-amber-50 text-amber-700";
|
||||
}
|
||||
if (status === "missed") {
|
||||
return "border-red-200 bg-red-50 text-red-700";
|
||||
}
|
||||
if (status === "leave" || status === "travel" || status === "holiday" || status === "weekend") {
|
||||
return "border-zinc-200 bg-zinc-100 text-zinc-600";
|
||||
}
|
||||
return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||
}
|
||||
|
||||
function buildBackfillState(item: AttendanceTeamItem, workDate: string): BackfillState {
|
||||
return {
|
||||
employeeId: item.employeeId,
|
||||
employeeName: item.employeeName,
|
||||
workDate,
|
||||
clockInAt: item.clockInAt ? toDateTimeLocalValue(item.clockInAt) : "",
|
||||
clockOutAt: item.clockOutAt ? toDateTimeLocalValue(item.clockOutAt) : "",
|
||||
note: item.note ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function toIsoOrNull(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) return null;
|
||||
return new Date(normalized).toISOString();
|
||||
}
|
||||
|
||||
function toDateTimeLocalValue(value: string) {
|
||||
const date = new Date(value);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function canBackfillItem(item: AttendanceTeamItem) {
|
||||
return item.needClockIn && (!item.clockInAt || !item.clockOutAt);
|
||||
}
|
||||
|
||||
function exemptionReasonLabel(reason: AttendanceMonthItem["exemptionReason"] | AttendanceTeamItem["exemptionReason"]) {
|
||||
if (reason === "leave") return "已批准请假";
|
||||
if (reason === "travel") return "已批准差旅";
|
||||
if (reason === "holiday") return "节假日";
|
||||
if (reason === "weekend") return "周末";
|
||||
if (reason === "policy_disabled") return "策略停用";
|
||||
return "-";
|
||||
}
|
||||
|
||||
export function AttendancePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions } = useAuth();
|
||||
const canManageTeam = hasPermission(permissions, "system.users");
|
||||
const canManagePolicy = hasPermission(permissions, "system.settings");
|
||||
const [activeTab, setActiveTab] = useState<AttendanceTab>("mine");
|
||||
const [monthValue, setMonthValue] = useState(currentMonthInput());
|
||||
const [teamDate, setTeamDate] = useState(currentDateInput());
|
||||
const [teamDepartmentId, setTeamDepartmentId] = useState("");
|
||||
const [policyForm, setPolicyForm] = useState<PolicyFormState>(toPolicyForm());
|
||||
const [backfillDialog, setBackfillDialog] = useState<BackfillState | null>(null);
|
||||
|
||||
const [year, month] = monthValue.split("-").map(Number);
|
||||
const disabledTab =
|
||||
activeTab === "team" && !canManageTeam
|
||||
? "mine"
|
||||
: activeTab === "settings" && !canManagePolicy
|
||||
? (canManageTeam ? "team" : "mine")
|
||||
: activeTab;
|
||||
|
||||
useEffect(() => {
|
||||
if (disabledTab !== activeTab) {
|
||||
setActiveTab(disabledTab);
|
||||
}
|
||||
}, [activeTab, disabledTab]);
|
||||
|
||||
const todayQuery = useQuery({
|
||||
queryKey: ["attendance", "me", "today"],
|
||||
queryFn: () => fetchMyTodayAttendance(),
|
||||
});
|
||||
|
||||
const monthQuery = useQuery({
|
||||
enabled: !isAttendanceDisabledError(todayQuery.error),
|
||||
queryKey: ["attendance", "me", "month", year, month],
|
||||
queryFn: () => fetchMyMonthAttendance({ year, month }),
|
||||
});
|
||||
|
||||
const departmentsQuery = useQuery({
|
||||
enabled: canManageTeam,
|
||||
queryKey: ["departments", "attendance", "active"],
|
||||
queryFn: () => fetchDepartments({ includeArchived: false }),
|
||||
});
|
||||
|
||||
const teamQuery = useQuery({
|
||||
enabled: canManageTeam && !isAttendanceDisabledError(todayQuery.error),
|
||||
queryKey: ["attendance", "team", teamDate, teamDepartmentId],
|
||||
queryFn: () =>
|
||||
fetchTeamAttendance({
|
||||
date: teamDate,
|
||||
departmentId: teamDepartmentId || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const policyQuery = useQuery({
|
||||
enabled: canManagePolicy && !isAttendanceDisabledError(todayQuery.error),
|
||||
queryKey: ["attendance", "policy"],
|
||||
queryFn: () => fetchAttendancePolicy(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (policyQuery.data?.item) {
|
||||
setPolicyForm(toPolicyForm(policyQuery.data.item));
|
||||
}
|
||||
}, [policyQuery.data?.item]);
|
||||
|
||||
const clockInMutation = useMutation({
|
||||
mutationFn: () => clockInAttendance(),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "team"] }),
|
||||
]);
|
||||
toast.success("上班打卡成功");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
const clockOutMutation = useMutation({
|
||||
mutationFn: () => clockOutAttendance(),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "team"] }),
|
||||
]);
|
||||
toast.success("下班打卡成功");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
const policyMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateAttendancePolicy({
|
||||
workStartTime: policyForm.workStartTime,
|
||||
workEndTime: policyForm.workEndTime,
|
||||
lunchStartTime: policyForm.lunchStartTime,
|
||||
lunchEndTime: policyForm.lunchEndTime,
|
||||
lateToleranceMinutes: Number(policyForm.lateToleranceMinutes || 0),
|
||||
}),
|
||||
onSuccess: async (response) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "policy"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "team"] }),
|
||||
]);
|
||||
setPolicyForm(toPolicyForm(response.item));
|
||||
toast.success("考勤策略已保存");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
const backfillMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!backfillDialog) {
|
||||
throw new Error("未选择补卡对象");
|
||||
}
|
||||
return adminBackfillAttendance({
|
||||
employeeId: backfillDialog.employeeId,
|
||||
workDate: backfillDialog.workDate,
|
||||
clockInAt: toIsoOrNull(backfillDialog.clockInAt),
|
||||
clockOutAt: toIsoOrNull(backfillDialog.clockOutAt),
|
||||
note: backfillDialog.note.trim() || null,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "team"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
|
||||
]);
|
||||
setBackfillDialog(null);
|
||||
toast.success("补卡已保存");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
const today = todayQuery.data;
|
||||
const monthData = monthQuery.data;
|
||||
const departments = departmentsQuery.data?.items ?? [];
|
||||
const teamItems = teamQuery.data?.items ?? [];
|
||||
const featureDisabled = isAttendanceDisabledError(todayQuery.error);
|
||||
|
||||
if (featureDisabled) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="考勤" subtitle="上下班打卡、月度统计和团队补卡入口。" />
|
||||
<div className="card-std p-6 text-sm text-zinc-600">
|
||||
考勤模块当前未启用,请联系管理员检查环境变量 `ATTENDANCE_ENABLED` 与功能开关 `attendance_v1`。
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="考勤" subtitle="上下班打卡、月度统计和团队补卡入口。" />
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as AttendanceTab)}>
|
||||
<TabsList className="flex w-full flex-wrap justify-start gap-1">
|
||||
<TabsTrigger value="mine">我的考勤</TabsTrigger>
|
||||
{canManageTeam ? <TabsTrigger value="team">团队考勤</TabsTrigger> : null}
|
||||
{canManagePolicy ? <TabsTrigger value="settings">考勤设置</TabsTrigger> : null}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="mine" className="space-y-6">
|
||||
<SectionPanel
|
||||
title="今日打卡状态"
|
||||
description="工作日支持上下班打卡;请假、差旅、节假日与周末会自动免打卡。"
|
||||
actions={
|
||||
today?.needClockIn ? (
|
||||
today.item?.clockInAt ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => clockOutMutation.mutate()}
|
||||
disabled={clockOutMutation.isPending}
|
||||
variant="secondary"
|
||||
>
|
||||
{today.item.clockOutAt ? "重新打下班卡" : "点击下班"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => clockInMutation.mutate()}
|
||||
disabled={clockInMutation.isPending}
|
||||
>
|
||||
点击打上班卡
|
||||
</Button>
|
||||
)
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{todayQuery.isLoading ? (
|
||||
<div className="text-sm text-zinc-500">加载今日打卡状态中...</div>
|
||||
) : todayQuery.isError ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveErrorMessage(todayQuery.error)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<MetricCard title="考勤日期" value={today?.workDate ?? "-"} />
|
||||
<MetricCard title="上班时间" value={formatDateTimeCompact(today?.item?.clockInAt ?? null)} />
|
||||
<MetricCard title="下班时间" value={formatDateTimeCompact(today?.item?.clockOutAt ?? null)} />
|
||||
<MetricCard
|
||||
title="今日工时"
|
||||
value={formatMinutes(today?.item?.workMinutes ?? null)}
|
||||
note={
|
||||
today?.needClockIn
|
||||
? today?.item?.clockInAt
|
||||
? today?.item?.clockOutAt
|
||||
? "今日打卡已完成"
|
||||
: "已打上班卡,待下班卡"
|
||||
: "今日尚未打上班卡"
|
||||
: `免打卡:${exemptionReasonLabel(today?.exemptionReason ?? null)}`
|
||||
}
|
||||
tone={today?.item?.isLate || today?.item?.isEarlyLeave ? "warning" : "neutral"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel
|
||||
title="本月统计"
|
||||
description="缺卡仅统计应出勤工作日;请假、差旅、节假日和周末自动排除。"
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="month"
|
||||
value={monthValue}
|
||||
onChange={(event) => setMonthValue(event.target.value || currentMonthInput())}
|
||||
className="w-[180px]"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{monthQuery.isLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-5">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} className="card-std h-24 animate-pulse bg-zinc-100" />
|
||||
))}
|
||||
</div>
|
||||
) : monthQuery.isError ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveErrorMessage(monthQuery.error)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-5">
|
||||
<MetricCard title="应出勤" value={monthData?.summary.totalWorkDays ?? 0} />
|
||||
<MetricCard title="已打卡" value={monthData?.summary.clockedDays ?? 0} />
|
||||
<MetricCard title="迟到" value={monthData?.summary.lateDays ?? 0} tone="warning" />
|
||||
<MetricCard title="早退" value={monthData?.summary.earlyLeaveDays ?? 0} tone="warning" />
|
||||
<MetricCard title="缺卡" value={monthData?.summary.missedDays ?? 0} tone={(monthData?.summary.missedDays ?? 0) > 0 ? "danger" : "neutral"} />
|
||||
</div>
|
||||
)}
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="本月打卡记录" description="逐日展示上下班时间、工时和状态。">
|
||||
{monthQuery.isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : monthQuery.isError ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveErrorMessage(monthQuery.error)}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>日期</TableHead>
|
||||
<TableHead>上班</TableHead>
|
||||
<TableHead>下班</TableHead>
|
||||
<TableHead>工时</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>备注</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{monthData?.items.map((item) => (
|
||||
<TableRow key={item.workDate}>
|
||||
<TableCell>{item.workDate}</TableCell>
|
||||
<TableCell>{formatDateTimeCompact(item.clockInAt)}</TableCell>
|
||||
<TableCell>{formatDateTimeCompact(item.clockOutAt)}</TableCell>
|
||||
<TableCell>{formatMinutes(item.workMinutes)}</TableCell>
|
||||
<TableCell>
|
||||
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${attendanceStatusClass(item.attendanceStatus)}`}>
|
||||
{attendanceStatusLabel(item.attendanceStatus)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{item.note ?? (item.needClockIn ? "-" : exemptionReasonLabel(item.exemptionReason))}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</SectionPanel>
|
||||
</TabsContent>
|
||||
|
||||
{canManageTeam ? (
|
||||
<TabsContent value="team" className="space-y-6">
|
||||
<SectionPanel
|
||||
title="团队考勤"
|
||||
description="查看指定日期团队上下班打卡状态,并为缺卡员工补卡。"
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input type="date" value={teamDate} onChange={(event) => setTeamDate(event.target.value || currentDateInput())} className="w-[170px]" />
|
||||
<select
|
||||
className="select-std h-10 w-[220px]"
|
||||
value={teamDepartmentId}
|
||||
onChange={(event) => setTeamDepartmentId(event.target.value)}
|
||||
>
|
||||
<option value="">全部部门</option>
|
||||
{departments.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{teamQuery.isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : teamQuery.isError ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveErrorMessage(teamQuery.error)}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>员工</TableHead>
|
||||
<TableHead>部门</TableHead>
|
||||
<TableHead>上班</TableHead>
|
||||
<TableHead>下班</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{teamItems.map((item) => (
|
||||
<TableRow key={item.employeeId}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.employeeName}</div>
|
||||
<div className="text-xs text-zinc-500">{item.employeeNo} · {item.title}</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.departmentName ?? "-"}</TableCell>
|
||||
<TableCell>{formatDateTimeCompact(item.clockInAt)}</TableCell>
|
||||
<TableCell>{formatDateTimeCompact(item.clockOutAt)}</TableCell>
|
||||
<TableCell>
|
||||
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${attendanceStatusClass(item.attendanceStatus)}`}>
|
||||
{attendanceStatusLabel(item.attendanceStatus)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{canBackfillItem(item) ? (
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => setBackfillDialog(buildBackfillState(item, teamDate))}>
|
||||
补卡
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</SectionPanel>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{canManagePolicy ? (
|
||||
<TabsContent value="settings" className="space-y-6">
|
||||
<SectionPanel title="考勤设置" description="维护全局工作时间、午休时间和迟到宽容分钟。">
|
||||
{policyQuery.isLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} className="h-20 animate-pulse rounded-2xl bg-zinc-100" />
|
||||
))}
|
||||
</div>
|
||||
) : policyQuery.isError ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveErrorMessage(policyQuery.error)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="attendanceWorkStart">上班时间</Label>
|
||||
<Input
|
||||
id="attendanceWorkStart"
|
||||
type="time"
|
||||
value={policyForm.workStartTime}
|
||||
onChange={(event) => setPolicyForm((prev) => ({ ...prev, workStartTime: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="attendanceWorkEnd">下班时间</Label>
|
||||
<Input
|
||||
id="attendanceWorkEnd"
|
||||
type="time"
|
||||
value={policyForm.workEndTime}
|
||||
onChange={(event) => setPolicyForm((prev) => ({ ...prev, workEndTime: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="attendanceLunchStart">午休开始</Label>
|
||||
<Input
|
||||
id="attendanceLunchStart"
|
||||
type="time"
|
||||
value={policyForm.lunchStartTime}
|
||||
onChange={(event) => setPolicyForm((prev) => ({ ...prev, lunchStartTime: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="attendanceLunchEnd">午休结束</Label>
|
||||
<Input
|
||||
id="attendanceLunchEnd"
|
||||
type="time"
|
||||
value={policyForm.lunchEndTime}
|
||||
onChange={(event) => setPolicyForm((prev) => ({ ...prev, lunchEndTime: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="attendanceTolerance">迟到宽容分钟</Label>
|
||||
<Input
|
||||
id="attendanceTolerance"
|
||||
type="number"
|
||||
min={0}
|
||||
value={policyForm.lateToleranceMinutes}
|
||||
onChange={(event) => setPolicyForm((prev) => ({ ...prev, lateToleranceMinutes: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<Button type="button" onClick={() => policyMutation.mutate()} disabled={policyMutation.isPending || policyQuery.isLoading}>
|
||||
保存设置
|
||||
</Button>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
|
||||
<Dialog open={Boolean(backfillDialog)} onOpenChange={(open) => !open && setBackfillDialog(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>管理员补卡</DialogTitle>
|
||||
</DialogHeader>
|
||||
{backfillDialog ? (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-zinc-600">
|
||||
为 <span className="font-medium text-zinc-900">{backfillDialog.employeeName}</span> 补录 {backfillDialog.workDate} 的考勤记录。
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="attendanceBackfillClockIn">上班时间</Label>
|
||||
<Input
|
||||
id="attendanceBackfillClockIn"
|
||||
type="datetime-local"
|
||||
value={backfillDialog.clockInAt}
|
||||
onChange={(event) => setBackfillDialog((prev) => prev ? { ...prev, clockInAt: event.target.value } : prev)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="attendanceBackfillClockOut">下班时间</Label>
|
||||
<Input
|
||||
id="attendanceBackfillClockOut"
|
||||
type="datetime-local"
|
||||
value={backfillDialog.clockOutAt}
|
||||
onChange={(event) => setBackfillDialog((prev) => prev ? { ...prev, clockOutAt: event.target.value } : prev)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="attendanceBackfillNote">备注</Label>
|
||||
<Textarea
|
||||
id="attendanceBackfillNote"
|
||||
rows={3}
|
||||
value={backfillDialog.note}
|
||||
onChange={(event) => setBackfillDialog((prev) => prev ? { ...prev, note: event.target.value } : prev)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setBackfillDialog(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" onClick={() => backfillMutation.mutate()} disabled={backfillMutation.isPending}>
|
||||
保存补卡
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AttendancePage;
|
||||
@@ -57,6 +57,7 @@ function toDisplayName(flagKey: string) {
|
||||
if (flagKey === "workflow_definition_admin_v1") return "流程定义治理";
|
||||
if (flagKey === "cutover_console_v1") return "切换控制台";
|
||||
if (flagKey === "presence_board_v1") return "团队状态板";
|
||||
if (flagKey === "attendance_v1") return "极简考勤";
|
||||
if (flagKey === "weekly_report_v1") return "周报工作台";
|
||||
if (flagKey === "employee_lifecycle_v1") return "人员生命周期";
|
||||
if (flagKey === "todo_board_v1") return "待办工作台";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ApOperationsPage,
|
||||
ApprovalRulesPage,
|
||||
AnnouncementsPage,
|
||||
AttendancePage,
|
||||
AssetRegistryPage,
|
||||
AuditLogsPage,
|
||||
CutoverConsolePage,
|
||||
@@ -142,6 +143,11 @@ export function AppRouter() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="attendance"
|
||||
element={withRouteFallback(<AttendancePage />)}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="reimbursements"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { AttendancePage as default } from "../../pages/AttendancePage";
|
||||
@@ -1,6 +1,7 @@
|
||||
import { lazy } from "react";
|
||||
|
||||
export const ApOperationsPage = lazy(() => import("./ApOperationsPage"));
|
||||
export const AttendancePage = lazy(() => import("./AttendancePage"));
|
||||
export const AuditLogsPage = lazy(() => import("./AuditLogsPage"));
|
||||
export const ApprovalRulesPage = lazy(() => import("./ApprovalRulesPage"));
|
||||
export const AssetRegistryPage = lazy(() => import("./AssetRegistryPage"));
|
||||
|
||||
在新工单中引用
屏蔽一个用户