diff --git a/client/src/api/attendance.ts b/client/src/api/attendance.ts index 15e0c00e..259e8146 100644 --- a/client/src/api/attendance.ts +++ b/client/src/api/attendance.ts @@ -1,168 +1,191 @@ import { apiRequestData } from "./http"; -export type AttendanceExemptionReason = - | "leave" - | "travel" - | "holiday" - | "weekend" - | "policy_disabled"; - -export type AttendanceStatus = +export type AttendanceCorrectionKind = "missed_punch" | "out" | "overtime"; +export type AttendanceCorrectionStatus = "reviewing" | "approved" | "rejected" | "cancelled"; +export type AttendanceDailyStatus = | "normal" - | "late" - | "early_leave" - | "late_early_leave" - | "pending_clock_out" - | "missed" + | "exception" + | "missing" | "leave" - | "travel" - | "holiday" + | "corrected" + | "overtime" | "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; +export type AttendanceEmployeeSummary = { + id: string; employeeNo: string; - title: string; + name: string; + title?: string | null; 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; +export type WecomCheckinSummary = { + id: string; + checkinAt: string | null; + checkinDate: string | null; + checkinType: string; + result: string | null; + exceptionType: string | null; + sourceType: string | null; +}; + +export type AttendanceCorrectionItem = { + id: string; + kind: AttendanceCorrectionKind; + status: AttendanceCorrectionStatus; + workDate: string | null; + missedPunchAt: string | null; + startAt: string | null; + endAt: string | null; + reason: string; + locationSummary: string | null; + compensationPolicy: string | null; + owner: AttendanceEmployeeSummary | null; createdAt: string | null; updatedAt: string | null; - updatedBy: string | null; + workflow?: { + id: string; + status: string; + currentStepId: 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 type AttendanceMonthlyDay = { + date: string; + status: AttendanceDailyStatus; + explanation: string; + firstCheckinAt: string | null; + lastCheckinAt: string | null; + checkins: WecomCheckinSummary[]; + corrections: AttendanceCorrectionItem[]; + leaveRequests: Array<{ + id: string; + leaveTypeName: string; + requestedDays: number; + reason: string | null; + }>; +}; -export async function clockOutAttendance(note?: string | null) { - return apiRequestData<{ item: AttendanceRecordItem }>("/api/attendance/clock-out", { - method: "POST", - body: JSON.stringify(note ? { note } : {}), - }); -} +export type AttendanceMonthlyEmployee = { + employee: AttendanceEmployeeSummary | null; + days: AttendanceMonthlyDay[]; +}; -export async function fetchMyTodayAttendance() { - return apiRequestData("/api/attendance/me/today"); -} +export type AttendanceMonthlyResponse = { + month: string; + summary: { + employeeCount: number; + workDays: number; + checkinDays: number; + missingDays: number; + approvedCorrectionCount: number; + }; + employees: AttendanceMonthlyEmployee[]; +}; -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(`/api/attendance/me/month${query ? `?${query}` : ""}`); -} +export type WecomCheckinSyncRun = { + id: string; + startDate: string | null; + endDate: string | null; + status: "running" | "success" | "skipped" | "failed" | string; + employeeCount: number; + recordCount: number; + errorCode: string | null; + errorMessage: string | null; + triggeredBy: AttendanceEmployeeSummary | null; + createdAt: string | null; + updatedAt: string | null; +}; -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; +export type AttendanceCorrectionCreatePayload = { + kind: AttendanceCorrectionKind; workDate: string; - clockInAt?: string | null; - clockOutAt?: string | null; - note?: string | null; + missedPunchAt?: string; + startAt?: string; + endAt?: string; + reason: string; + locationSummary?: string; + compensationPolicy?: string; +}; + +export async function fetchAttendanceMonthly(params?: { month?: string; employeeId?: string }) { + const search = new URLSearchParams(); + if (params?.month) search.set("month", params.month); + if (params?.employeeId) search.set("employeeId", params.employeeId); + const query = search.toString(); + return apiRequestData(`/api/attendance/monthly${query ? `?${query}` : ""}`); +} + +export async function triggerWecomAttendanceSync(payload: { + startDate: string; + endDate: string; + employeeIds?: string[]; }) { - return apiRequestData<{ item: AttendanceRecordItem }>("/api/attendance/admin/backfill", { + return apiRequestData<{ + item: WecomCheckinSyncRun; + skipped: boolean; + code: string; + employeeCount: number; + boundUserCount: number; + recordCount: number; + }>("/api/attendance/wecom/sync", { method: "POST", body: JSON.stringify(payload), }); } + +export async function fetchWecomAttendanceSyncRuns(params?: { page?: number; pageSize?: number }) { + const search = new URLSearchParams(); + if (params?.page) search.set("page", String(params.page)); + if (params?.pageSize) search.set("pageSize", String(params.pageSize)); + const query = search.toString(); + return apiRequestData<{ + page: number; + pageSize: number; + total: number; + items: WecomCheckinSyncRun[]; + }>(`/api/attendance/wecom/sync-runs${query ? `?${query}` : ""}`); +} + +export async function createAttendanceCorrection(payload: AttendanceCorrectionCreatePayload) { + return apiRequestData<{ + item: AttendanceCorrectionItem; + workflowInstanceId: string; + currentTaskId: string; + }>("/api/attendance-corrections", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function fetchAttendanceCorrections(params?: { + month?: string; + employeeId?: string; + status?: AttendanceCorrectionStatus; + kind?: AttendanceCorrectionKind; + page?: number; + pageSize?: number; +}) { + const search = new URLSearchParams(); + if (params?.month) search.set("month", params.month); + if (params?.employeeId) search.set("employeeId", params.employeeId); + if (params?.status) search.set("status", params.status); + if (params?.kind) search.set("kind", params.kind); + if (params?.page) search.set("page", String(params.page)); + if (params?.pageSize) search.set("pageSize", String(params.pageSize)); + const query = search.toString(); + return apiRequestData<{ + page: number; + pageSize: number; + total: number; + items: AttendanceCorrectionItem[]; + }>(`/api/attendance-corrections${query ? `?${query}` : ""}`); +} + +export async function cancelAttendanceCorrection(id: string) { + return apiRequestData<{ item: AttendanceCorrectionItem }>(`/api/attendance-corrections/${id}/cancel`, { + method: "POST", + body: JSON.stringify({}), + }); +} diff --git a/client/src/api/errorMessages.ts b/client/src/api/errorMessages.ts index 3ffd64b1..e1a901c4 100644 --- a/client/src/api/errorMessages.ts +++ b/client/src/api/errorMessages.ts @@ -14,7 +14,8 @@ const FEATURE_FLAG_DISABLED_MESSAGES: Record = { portal_home_v1_disabled: "Portal 首页当前未启用,请联系管理员检查环境开关与功能开关配置。", announcements_v1_disabled: "公告模块当前未启用,请联系管理员检查环境开关与功能开关配置。", presence_board_v1_disabled: "团队状态模块当前未启用,请联系管理员检查环境开关与功能开关配置。", - attendance_v1_disabled: "考勤模块当前未启用,请联系管理员检查环境开关与功能开关配置。", + attendance_v1_disabled: "历史 OA 自建打卡已关闭,请在企业微信打卡。", + attendance_unified_v1_disabled: "企微考勤统一汇总当前未启用,请联系管理员检查环境开关与功能开关配置。", weekly_report_v1_disabled: "周报模块当前未启用,请联系管理员检查环境开关与功能开关配置。", employee_lifecycle_v1_disabled: "人员生命周期模块当前未启用,请联系管理员检查环境开关与功能开关配置。", todo_board_v1_disabled: "待办模块当前未启用,请联系管理员检查环境开关与功能开关配置。", diff --git a/client/src/api/featureFlags.ts b/client/src/api/featureFlags.ts index a086a61c..910707b5 100644 --- a/client/src/api/featureFlags.ts +++ b/client/src/api/featureFlags.ts @@ -14,6 +14,7 @@ export type FeatureFlagKey = | "cutover_console_v1" | "presence_board_v1" | "attendance_v1" + | "attendance_unified_v1" | "weekly_report_v1" | "employee_lifecycle_v1" | "todo_board_v1" diff --git a/client/src/api/workflow.ts b/client/src/api/workflow.ts index 91a00b44..5aa81d34 100644 --- a/client/src/api/workflow.ts +++ b/client/src/api/workflow.ts @@ -139,6 +139,23 @@ export type WorkflowTaskDetail = { name: string; } | null; } | null; + attendanceCorrection: { + id: string; + status: string; + kind: "missed_punch" | "out" | "overtime" | string; + workDate: string | null; + missedPunchAt: string | null; + startAt: string | null; + endAt: string | null; + reason: string | null; + locationSummary: string | null; + compensationPolicy: string | null; + owner: { + id: string; + employeeNo: string; + name: string; + } | null; + } | null; purchaseRequest: { id: string; requestNo: string | null; @@ -228,7 +245,7 @@ export type WorkflowApproverListResponse = { export type WorkflowTaskListQuery = { status?: "pending" | "completed" | "rejected" | "cancelled" | "skipped"; - entityType?: "reimbursement" | "leave" | "purchase_request" | "travel_request"; + entityType?: "reimbursement" | "leave" | "attendance_correction" | "purchase_request" | "travel_request"; page?: number; pageSize?: number; }; diff --git a/client/src/auth/permissions.ts b/client/src/auth/permissions.ts index bf12c12a..535b1831 100644 --- a/client/src/auth/permissions.ts +++ b/client/src/auth/permissions.ts @@ -16,6 +16,12 @@ const LEAVE_SELF_SERVICE_PERMISSIONS = [ "leave.submit", ] as const; +const ATTENDANCE_SELF_SERVICE_PERMISSIONS = [ + "attendance.create", + "attendance.view_own", + "attendance.submit", +] as const; + const PROCUREMENT_SELF_SERVICE_PERMISSIONS = [ "procurement.create", "procurement.view_own", @@ -37,6 +43,7 @@ const MASTERDATA_MANAGE_PERMISSIONS = ["masterdata.manage"] as const; const EMPLOYEE_BASE_PERMISSIONS = [ ...EXPENSE_SELF_SERVICE_PERMISSIONS, ...LEAVE_SELF_SERVICE_PERMISSIONS, + ...ATTENDANCE_SELF_SERVICE_PERMISSIONS, ...TRAVEL_SELF_SERVICE_PERMISSIONS, ...PROJECT_SELF_SERVICE_PERMISSIONS, ] as const; @@ -55,6 +62,8 @@ const ROLE_PERMISSIONS: Record = { "expense.view_all", "leave.approve", "leave.view_all", + "attendance.approve", + "attendance.view_all", "procurement.approve", "procurement.view_all", "travel.approve", @@ -75,6 +84,8 @@ const ROLE_PERMISSIONS: Record = { "expense.view_all", "leave.approve", "leave.view_all", + "attendance.approve", + "attendance.view_all", "procurement.approve", "procurement.view_all", "travel.approve", @@ -98,6 +109,7 @@ const ROLE_PERMISSIONS: Record = { "travel.view_all", "travel.payment.manage", "asset.view_all", + "attendance.view_all", ], finance_mgr: [ ...EMPLOYEE_BASE_PERMISSIONS, @@ -128,6 +140,9 @@ const ROLE_PERMISSIONS: Record = { "payment.batch.manage", "risk.review.manage", "project.view_all", + "attendance.view_all", + "attendance.manage", + "attendance.sync", ], administration: [ "expense.proxy_submit", @@ -142,11 +157,18 @@ const ROLE_PERMISSIONS: Record = { "asset.transfer", "project.view_all", "project.manage", + "attendance.view_all", + "attendance.manage", ], hr: [ ...LEAVE_SELF_SERVICE_PERMISSIONS, + ...ATTENDANCE_SELF_SERVICE_PERMISSIONS, "leave.view_all", "leave.manage", + "attendance.approve", + "attendance.view_all", + "attendance.manage", + "attendance.sync", "system.users", "system.users_sensitive", "expense.view_all", @@ -158,6 +180,7 @@ const ROLE_PERMISSIONS: Record = { export const BUSINESS_APPROVAL_PERMISSIONS = [ "expense.approve", "leave.approve", + "attendance.approve", "procurement.approve", "travel.approve", ] as const; diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index 5e5272ba..aeef704a 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -35,7 +35,7 @@ export const NAV_ITEMS: NavItem[] = [ { to: "/system/settings", label: "系统设置", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage"] }, { to: "/system/feature-flags", label: "功能开关", icon: "toggle_on", anyOf: ["system.settings"] }, { to: "/audit-logs", label: "审计日志", icon: "manage_search", anyOf: ["system.audit.view"] }, - { to: "/workflow/tasks", label: "审批待办", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"] }, + { to: "/workflow/tasks", label: "审批待办", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] }, { to: "/contracts-registry", label: "合同台账", icon: "description", anyOf: ["project.view_all", "project.manage", "system.settings"] }, { to: "/purchase-requests", label: "采购申请", icon: "shopping_cart", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] }, { to: "/travel-requests", label: "差旅申请", icon: "flight", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage", "integration.manage"] }, diff --git a/client/src/components/onboarding/WelcomeDialog.tsx b/client/src/components/onboarding/WelcomeDialog.tsx index 0458e128..98fbbd65 100644 --- a/client/src/components/onboarding/WelcomeDialog.tsx +++ b/client/src/components/onboarding/WelcomeDialog.tsx @@ -31,8 +31,8 @@ const WELCOME_LINKS: WelcomeLinkItem[] = [ }, { icon: "schedule", - label: "考勤打卡", - description: "查看今日状态并完成打卡", + label: "考勤汇总", + description: "查看企微打卡摘要并提交 OA 修正", to: "/attendance", }, { diff --git a/client/src/lib/businessDisplay.ts b/client/src/lib/businessDisplay.ts index 92406d03..d34265ac 100644 --- a/client/src/lib/businessDisplay.ts +++ b/client/src/lib/businessDisplay.ts @@ -1,10 +1,11 @@ import { isUuidLike } from "./reimbursementDisplay"; -export type BusinessEntityType = "reimbursement" | "leave" | "purchase_request" | "travel_request" | string | null | undefined; +export type BusinessEntityType = "reimbursement" | "leave" | "attendance_correction" | "purchase_request" | "travel_request" | string | null | undefined; const ENTITY_NOUN_LABELS: Record = { reimbursement: "报销单", leave: "请假", + attendance_correction: "考勤修正", purchase_request: "采购单", travel_request: "差旅单", }; @@ -12,6 +13,7 @@ const ENTITY_NOUN_LABELS: Record = { const ENTITY_SHORT_LABELS: Record = { reimbursement: "报销", leave: "请假", + attendance_correction: "考勤", purchase_request: "采购", travel_request: "差旅", }; diff --git a/client/src/lib/notificationLinks.ts b/client/src/lib/notificationLinks.ts index 587bf51b..cf7f22d1 100644 --- a/client/src/lib/notificationLinks.ts +++ b/client/src/lib/notificationLinks.ts @@ -22,6 +22,10 @@ export function resolveNotificationTargetLink(item: NotificationTarget) { return `/leave-requests?highlight=${item.targetId}`; } + if (item.targetType === "attendance_correction") { + return "/attendance"; + } + if (item.targetType === "purchase_request") { return `/purchase-requests?highlight=${item.targetId}`; } @@ -60,6 +64,10 @@ export function resolveMobileNotificationTargetLink(item: NotificationTarget) { return `/mobile/leave/${encodeSegment(item.targetId)}`; } + if (item.targetType === "attendance_correction") { + return "/attendance"; + } + if (item.targetType === "purchase_request") { return `/mobile/purchase-requests/${encodeSegment(item.targetId)}`; } diff --git a/client/src/pages/AttendancePage.tsx b/client/src/pages/AttendancePage.tsx index 1b06b6ee..46d18ea3 100644 --- a/client/src/pages/AttendancePage.tsx +++ b/client/src/pages/AttendancePage.tsx @@ -1,22 +1,19 @@ -import { useEffect, useState } from "react"; +import { type ReactNode, useMemo, 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, + cancelAttendanceCorrection, + createAttendanceCorrection, + fetchAttendanceCorrections, + fetchAttendanceMonthly, + fetchWecomAttendanceSyncRuns, + triggerWecomAttendanceSync, + type AttendanceCorrectionKind, + type AttendanceCorrectionStatus, + type AttendanceDailyStatus, + type AttendanceMonthlyDay, } from "../api/attendance"; +import { mapApiErrorMessage } from "../api/errorMessages"; import { isApiError } from "../api/types"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; @@ -25,35 +22,23 @@ 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 AttendanceTab = "summary" | "corrections" | "sync"; -type BackfillState = { - employeeId: string; - employeeName: string; +type CorrectionFormState = { + kind: AttendanceCorrectionKind; workDate: string; - clockInAt: string; - clockOutAt: string; - note: string; + missedPunchAt: string; + startAt: string; + endAt: string; + reason: string; + locationSummary: string; + compensationPolicy: string; }; function currentDateInput() { @@ -69,14 +54,38 @@ function currentMonthInput() { 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 firstDayOfMonth(month: string) { + return `${month}-01`; +} + +function toIsoOrUndefined(value: string) { + const normalized = value.trim(); + if (!normalized) return undefined; + return new Date(normalized).toISOString(); +} + +function formatDateTime(value: string | null) { + if (!value) return "-"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "-"; + return date.toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); +} + +function formatTime(value: string | null) { + if (!value) return "-"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "-"; + return date.toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); } function resolveErrorMessage(error: unknown, fallback = "请求失败,请稍后重试") { @@ -89,592 +98,492 @@ function resolveErrorMessage(error: unknown, fallback = "请求失败,请稍 return fallback; } -function isAttendanceDisabledError(error: unknown) { - return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_v1_disabled"; +function isUnifiedDisabledError(error: unknown) { + return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_unified_v1_disabled"; } -function formatDateTimeCompact(value: string | null) { - if (!value) return "-"; - return new Date(value).toLocaleTimeString("zh-CN", { hour12: false }); +function correctionKindLabel(kind: AttendanceCorrectionKind) { + if (kind === "missed_punch") return "补卡"; + if (kind === "out") return "外出"; + return "加班"; } -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 correctionStatusLabel(status: AttendanceCorrectionStatus) { + if (status === "reviewing") return "审批中"; + if (status === "approved") return "已通过"; + if (status === "rejected") return "已驳回"; + return "已撤回"; } -function attendanceStatusLabel(status: AttendanceStatus) { +function dayStatusLabel(status: AttendanceDailyStatus) { 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 === "exception") return "异常"; + if (status === "missing") return "缺卡"; if (status === "leave") return "请假"; - if (status === "travel") return "差旅"; - if (status === "holiday") return "节假日"; + if (status === "corrected") return "已解释"; + if (status === "overtime") 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 statusClass(status: AttendanceDailyStatus | AttendanceCorrectionStatus) { + if (status === "normal" || status === "approved") return "border-emerald-200 bg-emerald-50 text-emerald-700"; + if (status === "missing" || status === "rejected") return "border-red-200 bg-red-50 text-red-700"; + if (status === "exception" || status === "reviewing") return "border-amber-200 bg-amber-50 text-amber-700"; + return "border-slate-200 bg-slate-50 text-slate-600"; } -function buildBackfillState(item: AttendanceTeamItem, workDate: string): BackfillState { +function buildDefaultForm(): CorrectionFormState { + const today = currentDateInput(); return { - employeeId: item.employeeId, - employeeName: item.employeeName, - workDate, - clockInAt: item.clockInAt ? toDateTimeLocalValue(item.clockInAt) : "", - clockOutAt: item.clockOutAt ? toDateTimeLocalValue(item.clockOutAt) : "", - note: item.note ?? "", + kind: "missed_punch", + workDate: today, + missedPunchAt: `${today}T09:00`, + startAt: `${today}T09:00`, + endAt: `${today}T18:00`, + reason: "", + locationSummary: "", + compensationPolicy: "", }; } -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 "-"; +function dailyCheckinText(day: AttendanceMonthlyDay) { + if (!day.checkins.length) return "-"; + if (day.firstCheckinAt === day.lastCheckinAt) return formatTime(day.firstCheckinAt); + return `${formatTime(day.firstCheckinAt)} - ${formatTime(day.lastCheckinAt)}`; } 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("mine"); - const [monthValue, setMonthValue] = useState(currentMonthInput()); - const [teamDate, setTeamDate] = useState(currentDateInput()); - const [teamDepartmentId, setTeamDepartmentId] = useState(""); - const [policyForm, setPolicyForm] = useState(toPolicyForm()); - const [backfillDialog, setBackfillDialog] = useState(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 { me, permissions } = useAuth(); + const canSync = hasPermission(permissions, "attendance.sync"); + const canViewAll = hasPermission(permissions, "attendance.view_all"); + const [activeTab, setActiveTab] = useState("summary"); + const [month, setMonth] = useState(currentMonthInput()); + const [form, setForm] = useState(() => buildDefaultForm()); + const [syncRange, setSyncRange] = useState({ + startDate: firstDayOfMonth(currentMonthInput()), + endDate: currentDateInput(), }); - const monthQuery = useQuery({ - enabled: !isAttendanceDisabledError(todayQuery.error), - queryKey: ["attendance", "me", "month", year, month], - queryFn: () => fetchMyMonthAttendance({ year, month }), + const monthlyQuery = useQuery({ + queryKey: ["attendance", "unified", "monthly", month], + queryFn: () => fetchAttendanceMonthly({ month }), }); - const departmentsQuery = useQuery({ - enabled: canManageTeam, - queryKey: ["departments", "attendance", "active"], - queryFn: () => fetchDepartments({ includeArchived: false }), + const correctionsQuery = useQuery({ + queryKey: ["attendance", "corrections", month], + queryFn: () => fetchAttendanceCorrections({ month, pageSize: 50 }), }); - const teamQuery = useQuery({ - enabled: canManageTeam && !isAttendanceDisabledError(todayQuery.error), - queryKey: ["attendance", "team", teamDate, teamDepartmentId], - queryFn: () => - fetchTeamAttendance({ - date: teamDate, - departmentId: teamDepartmentId || undefined, - }), + const syncRunsQuery = useQuery({ + enabled: canSync, + queryKey: ["attendance", "wecom", "sync-runs"], + queryFn: () => fetchWecomAttendanceSyncRuns({ pageSize: 10 }), }); - 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({ + const createMutation = useMutation({ mutationFn: () => - updateAttendancePolicy({ - workStartTime: policyForm.workStartTime, - workEndTime: policyForm.workEndTime, - lunchStartTime: policyForm.lunchStartTime, - lunchEndTime: policyForm.lunchEndTime, - lateToleranceMinutes: Number(policyForm.lateToleranceMinutes || 0), + createAttendanceCorrection({ + kind: form.kind, + workDate: form.workDate, + reason: form.reason.trim(), + missedPunchAt: form.kind === "missed_punch" ? toIsoOrUndefined(form.missedPunchAt) : undefined, + startAt: form.kind !== "missed_punch" ? toIsoOrUndefined(form.startAt) : undefined, + 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 (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 () => { + toast.success("考勤修正已提交审批"); + setForm(buildDefaultForm()); await Promise.all([ - queryClient.invalidateQueries({ queryKey: ["attendance", "team"] }), - queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }), + queryClient.invalidateQueries({ queryKey: ["attendance", "unified", "monthly"] }), + queryClient.invalidateQueries({ queryKey: ["attendance", "corrections"] }), + queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }), ]); - 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); + const cancelMutation = useMutation({ + mutationFn: (id: string) => cancelAttendanceCorrection(id), + onSuccess: async () => { + toast.success("考勤修正已撤回"); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["attendance", "unified", "monthly"] }), + queryClient.invalidateQueries({ queryKey: ["attendance", "corrections"] }), + ]); + }, + onError: (error) => { + toast.error(resolveErrorMessage(error)); + }, + }); + + const syncMutation = useMutation({ + mutationFn: () => triggerWecomAttendanceSync(syncRange), + onSuccess: async (response) => { + toast.success(response.skipped ? `企微同步跳过:${response.code}` : `企微同步完成:${response.recordCount} 条`); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["attendance", "unified", "monthly"] }), + queryClient.invalidateQueries({ queryKey: ["attendance", "wecom", "sync-runs"] }), + ]); + }, + onError: (error) => { + toast.error(resolveErrorMessage(error)); + }, + }); + + const featureDisabled = isUnifiedDisabledError(monthlyQuery.error); + const monthly = monthlyQuery.data; + const correctionItems = correctionsQuery.data?.items ?? []; + const ownCorrectionIds = useMemo( + () => new Set(correctionItems.filter((item) => item.owner?.id === me?.id).map((item) => item.id)), + [correctionItems, me?.id], + ); if (featureDisabled) { return ( -
- -
- 考勤模块当前未启用,请联系管理员检查环境变量 `ATTENDANCE_ENABLED` 与功能开关 `attendance_v1`。 -
-
+ + + +
+ 当前 `attendance_unified_v1` 未开启。历史 OA 自建上下班打卡已关闭;正式启用前请由管理员打开统一考勤功能开关。 +
+
+
); } return ( -
- + + + +
+ + + + +
setActiveTab(value as AttendanceTab)}> - - 我的考勤 - {canManageTeam ? 团队考勤 : null} - {canManagePolicy ? 考勤设置 : null} - +
+ + 月度汇总 + 修正申请 + {canSync ? 企微同步 : null} + +
+ + setMonth(event.target.value)} + /> +
+
- - clockOutMutation.mutate()} - disabled={clockOutMutation.isPending} - variant="secondary" - > - {today.item.clockOutAt ? "重新打下班卡" : "点击下班"} - - ) : ( - - ) - ) : null - } - > - {todayQuery.isLoading ? ( -
加载今日打卡状态中...
- ) : todayQuery.isError ? ( -
- {resolveErrorMessage(todayQuery.error)} -
- ) : ( -
- - - - -
- )} -
- - - setMonthValue(event.target.value || currentMonthInput())} - className="w-[180px]" - /> -
- } - > - {monthQuery.isLoading ? ( -
- {Array.from({ length: 5 }).map((_, index) => ( -
- ))} -
- ) : monthQuery.isError ? ( -
- {resolveErrorMessage(monthQuery.error)} -
- ) : ( -
- - - - - 0 ? "danger" : "neutral"} /> -
- )} - - - - {monthQuery.isLoading ? ( - - ) : monthQuery.isError ? ( -
- {resolveErrorMessage(monthQuery.error)} -
- ) : ( - - - - 日期 - 上班 - 下班 - 工时 - 状态 - 备注 - - - - {monthData?.items.map((item) => ( - - {item.workDate} - {formatDateTimeCompact(item.clockInAt)} - {formatDateTimeCompact(item.clockOutAt)} - {formatMinutes(item.workMinutes)} - - - {attendanceStatusLabel(item.attendanceStatus)} - - - {item.note ?? (item.needClockIn ? "-" : exemptionReasonLabel(item.exemptionReason))} - - ))} - -
- )} -
+ + {monthlyQuery.isLoading ? ( + + ) : monthlyQuery.isError ? ( +
+ {resolveErrorMessage(monthlyQuery.error)} +
+ ) : ( + monthly?.employees.map((employeeItem) => ( + +
+ + + + 日期 + 企微打卡 + 状态 + OA 解释 + + + + {employeeItem.days.map((day) => ( + + {day.date} + {dailyCheckinText(day)} + + + {dayStatusLabel(day.status)} + + + + {joinSmallText([ + day.explanation, + day.leaveRequests.map((item) => item.leaveTypeName).join("、"), + day.corrections.map((item) => `${correctionKindLabel(item.kind)}(${correctionStatusLabel(item.status)})`).join("、"), + ]) || "-"} + + + ))} + +
+
+
+ )) + )}
- {canManageTeam ? ( - - - setTeamDate(event.target.value || currentDateInput())} className="w-[170px]" /> + + +
+
+
+
- } - > - {teamQuery.isLoading ? ( - - ) : teamQuery.isError ? ( -
- {resolveErrorMessage(teamQuery.error)} +
+ + setForm((prev) => ({ ...prev, workDate: event.target.value }))} + /> +
+
+ + {form.kind === "missed_punch" ? ( +
+ + setForm((prev) => ({ ...prev, missedPunchAt: event.target.value }))} + />
) : ( +
+
+ + setForm((prev) => ({ ...prev, startAt: event.target.value }))} + /> +
+
+ + setForm((prev) => ({ ...prev, endAt: event.target.value }))} + /> +
+
+ )} + + {form.kind === "out" ? ( +
+ + setForm((prev) => ({ ...prev, locationSummary: event.target.value }))} + placeholder="客户现场 / 银行 / 办事窗口" + /> +
+ ) : null} + + {form.kind === "overtime" ? ( +
+ + setForm((prev) => ({ ...prev, compensationPolicy: event.target.value }))} + placeholder="调休 / 加班记录 / 其他" + /> +
+ ) : null} + +
+ +