import { type ReactNode, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { 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"; 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 { WorkspaceNotice } from "../components/patterns/ListWorkspace"; import { Button } from "../components/ui/button"; 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 = "summary" | "corrections" | "sync"; type CorrectionFormState = { kind: AttendanceCorrectionKind; workDate: string; missedPunchAt: string; startAt: string; endAt: string; reason: string; locationSummary: string; compensationPolicy: 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 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 = "请求失败,请稍后重试") { if (isApiError(error)) { return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; } if (error instanceof Error) { return error.message; } return fallback; } function isUnifiedDisabledError(error: unknown) { return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_unified_v1_disabled"; } function correctionKindLabel(kind: AttendanceCorrectionKind) { if (kind === "missed_punch") return "补卡"; if (kind === "out") return "外出"; return "加班"; } function correctionStatusLabel(status: AttendanceCorrectionStatus) { if (status === "reviewing") return "审批中"; if (status === "approved") return "已通过"; if (status === "rejected") return "已驳回"; return "已撤回"; } function dayStatusLabel(status: AttendanceDailyStatus) { if (status === "normal") return "正常"; if (status === "exception") return "异常"; if (status === "missing") return "缺卡"; if (status === "leave") return "请假"; if (status === "corrected") return "已解释"; if (status === "overtime") return "加班"; return "周末"; } 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 buildDefaultForm(): CorrectionFormState { const today = currentDateInput(); return { kind: "missed_punch", workDate: today, missedPunchAt: `${today}T09:00`, startAt: `${today}T09:00`, endAt: `${today}T18:00`, reason: "", locationSummary: "", compensationPolicy: "", }; } 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 { 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 monthlyQuery = useQuery({ queryKey: ["attendance", "unified", "monthly", month], queryFn: () => fetchAttendanceMonthly({ month }), }); const correctionsQuery = useQuery({ queryKey: ["attendance", "corrections", month], queryFn: () => fetchAttendanceCorrections({ month, pageSize: 50 }), }); const syncRunsQuery = useQuery({ enabled: canSync, queryKey: ["attendance", "wecom", "sync-runs"], queryFn: () => fetchWecomAttendanceSyncRuns({ pageSize: 10 }), }); const createMutation = useMutation({ mutationFn: () => 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 () => { toast.success("考勤修正已提交审批"); setForm(buildDefaultForm()); await Promise.all([ queryClient.invalidateQueries({ queryKey: ["attendance", "unified", "monthly"] }), queryClient.invalidateQueries({ queryKey: ["attendance", "corrections"] }), queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }), ]); }, onError: (error) => { toast.error(resolveErrorMessage(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 ( 日常打卡、请假和假期余额由企业微信承载;OA 只保留汇总、解释和审计入口。
当前 `attendance_unified_v1` 未开启。历史 OA 自建上下班打卡已关闭;正式启用前请由管理员打开统一考勤功能开关。
); } return ( 日常打卡仍在企业微信完成;OA 不写回企业微信,只展示汇总、修正审批和同步审计。
setActiveTab(value as AttendanceTab)}>
月度汇总 修正申请 {canSync ? 企微同步 : null}
setMonth(event.target.value)} />
{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("、"), ]) || "-"} ))}
)) )}
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}