diff --git a/client/src/api/attendance.ts b/client/src/api/attendance.ts new file mode 100644 index 00000000..15e0c00e --- /dev/null +++ b/client/src/api/attendance.ts @@ -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("/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(`/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), + }); +} diff --git a/client/src/api/errorMessages.ts b/client/src/api/errorMessages.ts index b3d21e28..035181be 100644 --- a/client/src/api/errorMessages.ts +++ b/client/src/api/errorMessages.ts @@ -12,6 +12,7 @@ const FEATURE_FLAG_DISABLED_MESSAGES: Record = { 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: "待办模块当前未启用,请联系管理员检查环境开关与功能开关配置。", diff --git a/client/src/api/featureFlags.ts b/client/src/api/featureFlags.ts index 8a877441..a086a61c 100644 --- a/client/src/api/featureFlags.ts +++ b/client/src/api/featureFlags.ts @@ -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" diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index afec8cf7..61578b1d 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -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" }, diff --git a/client/src/features/navigation/schema.ts b/client/src/features/navigation/schema.ts index bce342d2..afc98b43 100644 --- a/client/src/features/navigation/schema.ts +++ b/client/src/features/navigation/schema.ts @@ -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: "主导航" }, diff --git a/client/src/pages/AttendancePage.tsx b/client/src/pages/AttendancePage.tsx new file mode 100644 index 00000000..1b06b6ee --- /dev/null +++ b/client/src/pages/AttendancePage.tsx @@ -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("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 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 ( +
+ +
+ 考勤模块当前未启用,请联系管理员检查环境变量 `ATTENDANCE_ENABLED` 与功能开关 `attendance_v1`。 +
+
+ ); + } + + return ( +
+ + + setActiveTab(value as AttendanceTab)}> + + 我的考勤 + {canManageTeam ? 团队考勤 : null} + {canManagePolicy ? 考勤设置 : null} + + + + 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))} + + ))} + +
+ )} +
+ + + {canManageTeam ? ( + + + setTeamDate(event.target.value || currentDateInput())} className="w-[170px]" /> + +
+ } + > + {teamQuery.isLoading ? ( + + ) : teamQuery.isError ? ( +
+ {resolveErrorMessage(teamQuery.error)} +
+ ) : ( + + + + 员工 + 部门 + 上班 + 下班 + 状态 + 操作 + + + + {teamItems.map((item) => ( + + +
{item.employeeName}
+
{item.employeeNo} · {item.title}
+
+ {item.departmentName ?? "-"} + {formatDateTimeCompact(item.clockInAt)} + {formatDateTimeCompact(item.clockOutAt)} + + + {attendanceStatusLabel(item.attendanceStatus)} + + + + {canBackfillItem(item) ? ( + + ) : ( + - + )} + +
+ ))} +
+
+ )} + + + ) : null} + + {canManagePolicy ? ( + + + {policyQuery.isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, index) => ( +
+ ))} +
+ ) : policyQuery.isError ? ( +
+ {resolveErrorMessage(policyQuery.error)} +
+ ) : ( +
+
+ + setPolicyForm((prev) => ({ ...prev, workStartTime: event.target.value }))} + /> +
+
+ + setPolicyForm((prev) => ({ ...prev, workEndTime: event.target.value }))} + /> +
+
+ + setPolicyForm((prev) => ({ ...prev, lunchStartTime: event.target.value }))} + /> +
+
+ + setPolicyForm((prev) => ({ ...prev, lunchEndTime: event.target.value }))} + /> +
+
+ + setPolicyForm((prev) => ({ ...prev, lateToleranceMinutes: event.target.value }))} + /> +
+
+ )} +
+ +
+ + + ) : null} + + + !open && setBackfillDialog(null)}> + + + 管理员补卡 + + {backfillDialog ? ( +
+
+ 为 {backfillDialog.employeeName} 补录 {backfillDialog.workDate} 的考勤记录。 +
+
+
+ + setBackfillDialog((prev) => prev ? { ...prev, clockInAt: event.target.value } : prev)} + /> +
+
+ + setBackfillDialog((prev) => prev ? { ...prev, clockOutAt: event.target.value } : prev)} + /> +
+
+
+ +