R59 A1-A6:极简考勤打卡

- 新增 AttendanceRecord 与 AttendancePolicy 模型
- 上下班打卡、今日状态、月度统计端点
- 考勤管理页面(我的/团队/设置三 Tab)
- 移动端快速打卡入口
- 管理员补卡与审计日志
- 单测覆盖打卡幂等、迟到计算、工时计算、权限边界
这个提交包含在:
2026-04-11 21:57:27 +08:00
父节点 ba48e7d1d4
当前提交 677aa6cac3
共修改 25 个文件,包含 3081 行新增和 2 行删除
+168
查看文件
@@ -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),
});
}