ATTV1 实现考勤统一入口与企微同步
这个提交包含在:
+166
-143
@@ -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<AttendanceTodayResponse>("/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<AttendanceMonthResponse>(`/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<AttendanceMonthlyResponse>(`/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({}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ const FEATURE_FLAG_DISABLED_MESSAGES: Record<string, string> = {
|
||||
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: "待办模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
|
||||
|
||||
@@ -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"
|
||||
|
||||
+18
-1
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<string, readonly string[]> = {
|
||||
"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<string, readonly string[]> = {
|
||||
"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<string, readonly string[]> = {
|
||||
"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<string, readonly string[]> = {
|
||||
"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<string, readonly string[]> = {
|
||||
"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<string, readonly string[]> = {
|
||||
export const BUSINESS_APPROVAL_PERMISSIONS = [
|
||||
"expense.approve",
|
||||
"leave.approve",
|
||||
"attendance.approve",
|
||||
"procurement.approve",
|
||||
"travel.approve",
|
||||
] as const;
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -31,8 +31,8 @@ const WELCOME_LINKS: WelcomeLinkItem[] = [
|
||||
},
|
||||
{
|
||||
icon: "schedule",
|
||||
label: "考勤打卡",
|
||||
description: "查看今日状态并完成打卡",
|
||||
label: "考勤汇总",
|
||||
description: "查看企微打卡摘要并提交 OA 修正",
|
||||
to: "/attendance",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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<string, string> = {
|
||||
reimbursement: "报销单",
|
||||
leave: "请假",
|
||||
attendance_correction: "考勤修正",
|
||||
purchase_request: "采购单",
|
||||
travel_request: "差旅单",
|
||||
};
|
||||
@@ -12,6 +13,7 @@ const ENTITY_NOUN_LABELS: Record<string, string> = {
|
||||
const ENTITY_SHORT_LABELS: Record<string, string> = {
|
||||
reimbursement: "报销",
|
||||
leave: "请假",
|
||||
attendance_correction: "考勤",
|
||||
purchase_request: "采购",
|
||||
travel_request: "差旅",
|
||||
};
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
+451
-542
文件差异内容过多而无法显示
加载差异
@@ -57,7 +57,8 @@ 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 === "attendance_v1") return "历史 OA 极简打卡";
|
||||
if (flagKey === "attendance_unified_v1") return "企微考勤统一汇总";
|
||||
if (flagKey === "weekly_report_v1") return "周报工作台";
|
||||
if (flagKey === "employee_lifecycle_v1") return "人员生命周期";
|
||||
if (flagKey === "todo_board_v1") return "待办工作台";
|
||||
|
||||
@@ -53,7 +53,7 @@ type MobileAction = "approve" | "reject" | "return" | "transfer";
|
||||
type MobileTaskTab = "pending" | "completed";
|
||||
type EmployeeProgressTab = "draft" | "reviewing" | "approved" | "returned";
|
||||
type EmployeeProgressQueryStatus = "draft" | "reviewing" | "approved" | "rejected";
|
||||
type EntityFilter = "all" | "leave" | "reimbursement" | "purchase_request" | "travel_request";
|
||||
type EntityFilter = "all" | "leave" | "reimbursement" | "attendance_correction" | "purchase_request" | "travel_request";
|
||||
type ReplayClassification = "failed" | "conflicted";
|
||||
|
||||
type VisibleTaskCard = {
|
||||
@@ -85,6 +85,7 @@ const ENTITY_FILTER_OPTIONS: Array<{ value: EntityFilter; label: string }> = [
|
||||
{ value: "all", label: "全部" },
|
||||
{ value: "leave", label: "请假" },
|
||||
{ value: "reimbursement", label: "报销" },
|
||||
{ value: "attendance_correction", label: "考勤" },
|
||||
{ value: "purchase_request", label: "采购" },
|
||||
{ value: "travel_request", label: "差旅" },
|
||||
];
|
||||
@@ -118,6 +119,9 @@ function entityLabel(entityType: string | undefined | null) {
|
||||
if (entityType === "reimbursement") {
|
||||
return "报销";
|
||||
}
|
||||
if (entityType === "attendance_correction") {
|
||||
return "考勤";
|
||||
}
|
||||
if (entityType === "purchase_request") {
|
||||
return "采购";
|
||||
}
|
||||
@@ -596,12 +600,14 @@ export function MobileApprovalsPage() {
|
||||
const selectedApplicantName = selectedTaskCard?.applicantName ??
|
||||
selectedTask?.reimbursement?.submitter?.name ??
|
||||
selectedTask?.leaveRequest?.owner?.name ??
|
||||
selectedTask?.attendanceCorrection?.owner?.name ??
|
||||
selectedTask?.purchaseRequest?.applicant?.name ??
|
||||
selectedTask?.travelRequest?.applicant?.name ??
|
||||
null;
|
||||
const selectedMetric = selectedTaskCard?.metric ??
|
||||
selectedTask?.reimbursement?.totalAmount ??
|
||||
(selectedTask?.leaveRequest ? `${selectedTask.leaveRequest.requestedDays} 天` : null) ??
|
||||
(selectedTask?.attendanceCorrection ? selectedTask.attendanceCorrection.workDate : null) ??
|
||||
selectedTask?.purchaseRequest?.totalAmount ??
|
||||
selectedTask?.travelRequest?.estimatedAmount ??
|
||||
null;
|
||||
@@ -1387,6 +1393,15 @@ export function MobileApprovalsPage() {
|
||||
{joinBusinessMeta([`假期类型:${selectedTask.leaveRequest.leaveType?.name ?? "-"}`, `${selectedTask.leaveRequest.startDate ?? "-"} 至 ${selectedTask.leaveRequest.endDate ?? "-"}`])}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedTask.attendanceCorrection ? (
|
||||
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||
{joinBusinessMeta([
|
||||
`类型:${entityLabel("attendance_correction")}`,
|
||||
`日期:${selectedTask.attendanceCorrection.workDate ?? "-"}`,
|
||||
selectedTask.attendanceCorrection.reason ?? null,
|
||||
])}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedTask.purchaseRequest ? (
|
||||
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||
{joinBusinessMeta([`供应商:${selectedTask.purchaseRequest.supplier?.name ?? "-"}`, selectedTask.purchaseRequest.title])}
|
||||
|
||||
@@ -2,12 +2,6 @@ import { useEffect } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
clockInAttendance,
|
||||
clockOutAttendance,
|
||||
fetchMyTodayAttendance,
|
||||
type AttendanceTodayResponse,
|
||||
} from "../api/attendance";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { fetchNotifications, markNotificationRead, type NotificationItem } from "../api/notifications";
|
||||
import { fetchPortalHome } from "../api/portal";
|
||||
@@ -49,10 +43,6 @@ function resolveErrorMessage(error: unknown) {
|
||||
return "请求失败,请稍后重试。";
|
||||
}
|
||||
|
||||
function isAttendanceFeatureDisabled(error: unknown) {
|
||||
return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_v1_disabled";
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
@@ -70,30 +60,6 @@ function formatDateTime(value: string | null) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatShortTime(value: string | null) {
|
||||
if (!value) {
|
||||
return "--:--";
|
||||
}
|
||||
try {
|
||||
return new Date(value).toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
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 notificationTypeLabel(type: string) {
|
||||
if (type.startsWith("workflow.")) return "审批事件";
|
||||
if (type.startsWith("reimbursement.")) return "报销事件";
|
||||
@@ -104,31 +70,6 @@ function notificationTypeLabel(type: string) {
|
||||
return "系统事件";
|
||||
}
|
||||
|
||||
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 resolveAttendanceAction(attendanceToday: AttendanceTodayResponse | null) {
|
||||
if (!attendanceToday) {
|
||||
return null;
|
||||
}
|
||||
if (!attendanceToday.needClockIn) {
|
||||
return null;
|
||||
}
|
||||
if (!attendanceToday.item?.clockInAt) {
|
||||
return "clock-in" as const;
|
||||
}
|
||||
if (!attendanceToday.item.clockOutAt) {
|
||||
return "clock-out" as const;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ActionTile({ action }: { action: MobileAction }) {
|
||||
const isPrimary = action.tone === "primary";
|
||||
const isWarning = action.tone === "warning";
|
||||
@@ -176,12 +117,6 @@ export function MobilePortalPage() {
|
||||
const canCreateExpense = hasAnyPermission(permissions, ["expense.create"]);
|
||||
const currentMobileSource = `${location.pathname}${location.search}`;
|
||||
|
||||
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-items"],
|
||||
@@ -229,39 +164,6 @@ 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 attendanceToday = attendanceTodayQuery.data ?? null;
|
||||
const attendanceAction = resolveAttendanceAction(attendanceToday);
|
||||
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
|
||||
const pendingApprovalCount = canAccessApprovals
|
||||
? workflowSummaryQuery.data?.pendingTotal ?? pendingCountQuery.data?.total ?? 0
|
||||
: 0;
|
||||
@@ -320,6 +222,13 @@ export function MobilePortalPage() {
|
||||
to: "/mobile/leave?view=form",
|
||||
}
|
||||
: null,
|
||||
{
|
||||
key: "attendance",
|
||||
label: "考勤",
|
||||
meta: "汇总修正",
|
||||
icon: "schedule",
|
||||
to: "/attendance",
|
||||
},
|
||||
canAccessPurchase
|
||||
? {
|
||||
key: "purchase",
|
||||
@@ -443,50 +352,18 @@ export function MobilePortalPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!attendanceFeatureDisabled ? (
|
||||
<section className="mobile-card mobile-page-stack" data-testid="mobile-attendance-card">
|
||||
<div className="mobile-section-header">
|
||||
<div className="mobile-section-title">打卡</div>
|
||||
<span className="badge-neutral">{attendanceToday?.workDate ?? "今日"}</span>
|
||||
</div>
|
||||
|
||||
{attendanceTodayQuery.isLoading ? (
|
||||
<p className="text-sm text-zinc-500">读取中...</p>
|
||||
) : attendanceTodayQuery.isError ? (
|
||||
<p className="rounded-lg 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="text-sm text-zinc-600">{attendanceExemptionLabel(attendanceToday.exemptionReason)}</div>
|
||||
) : attendanceToday?.item?.clockInAt && attendanceToday.item.clockOutAt ? (
|
||||
<div className="text-sm text-zinc-600">
|
||||
{formatShortTime(attendanceToday.item.clockInAt)} - {formatShortTime(attendanceToday.item.clockOutAt)}
|
||||
<span className="ml-2 text-zinc-400">工时 {formatWorkMinutes(attendanceToday.item.workMinutes)}</span>
|
||||
</div>
|
||||
) : attendanceToday?.item?.clockInAt ? (
|
||||
<div className="text-sm text-zinc-600">已上班 {formatShortTime(attendanceToday.item.clockInAt)}</div>
|
||||
) : (
|
||||
<div className="text-sm text-zinc-600">今天还没打卡</div>
|
||||
)}
|
||||
|
||||
{attendanceAction ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-3 w-full"
|
||||
disabled={attendanceClockInMutation.isPending || attendanceClockOutMutation.isPending}
|
||||
onClick={() => {
|
||||
if (attendanceAction === "clock-in") {
|
||||
attendanceClockInMutation.mutate();
|
||||
} else {
|
||||
attendanceClockOutMutation.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{attendanceAction === "clock-in" ? "上班打卡" : "下班打卡"}
|
||||
</Button>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
<section className="mobile-card mobile-page-stack" data-testid="mobile-attendance-summary-card">
|
||||
<div className="mobile-section-header">
|
||||
<div className="mobile-section-title">考勤</div>
|
||||
<span className="badge-neutral">企业微信打卡</span>
|
||||
</div>
|
||||
<div className="text-sm leading-6 text-zinc-600">
|
||||
日常打卡在企业微信完成;补卡、外出、加班和月度解释在 OA 处理。
|
||||
</div>
|
||||
<Button asChild className="mt-3 w-full" size="sm" type="button" variant="secondary">
|
||||
<Link to="/attendance">查看考勤汇总</Link>
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section className="mobile-card mobile-page-stack" data-testid="mobile-notifications">
|
||||
<div className="mobile-section-header">
|
||||
|
||||
+11
-195
@@ -1,13 +1,6 @@
|
||||
import { type ReactNode, 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,
|
||||
type AttendanceTodayResponse,
|
||||
} from "../api/attendance";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import {
|
||||
fetchPortalHome,
|
||||
@@ -141,20 +134,6 @@ function formatWeeklyStatus(payload: PortalHomePayload["weeklyReport"]) {
|
||||
return payload.status === "submitted" ? "已提交" : "待填写";
|
||||
}
|
||||
|
||||
function resolveErrorMessage(error: unknown) {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return "请求失败,请稍后重试。";
|
||||
}
|
||||
|
||||
function isAttendanceFeatureDisabled(error: unknown) {
|
||||
return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_v1_disabled";
|
||||
}
|
||||
|
||||
function formatRelativeTime(value: string | null) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
@@ -184,30 +163,6 @@ function formatRelativeTime(value: string | null) {
|
||||
return `${diffDays} 天前`;
|
||||
}
|
||||
|
||||
function formatShortTime(value: string | null) {
|
||||
if (!value) {
|
||||
return "--:--";
|
||||
}
|
||||
try {
|
||||
return new Date(value).toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
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 buildTodoAggregateItems(payload: PortalHomePayload) {
|
||||
const cards = Array.isArray(payload.cards) ? payload.cards : [];
|
||||
const items: TodoAggregateItem[] = [];
|
||||
@@ -283,11 +238,8 @@ function resolveTeamStatusBadge(item: PortalTeamStatusItem) {
|
||||
if (item.presenceStatus === "remote") {
|
||||
return { label: "居家", className: "badge-neutral" };
|
||||
}
|
||||
if (!item.attendanceClockIn) {
|
||||
return { label: "未打卡", className: "badge-neutral" };
|
||||
}
|
||||
return {
|
||||
label: "已打卡",
|
||||
label: "考勤",
|
||||
className: "inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-slate-700 ring-1 ring-inset ring-slate-600/10",
|
||||
};
|
||||
}
|
||||
@@ -296,12 +248,6 @@ function formatTeamStatusAttendance(item: PortalTeamStatusItem) {
|
||||
if (item.onLeave) {
|
||||
return item.leaveType ? `${item.leaveType} 已审批` : "今日请假";
|
||||
}
|
||||
if (item.attendanceClockIn && item.attendanceClockOut) {
|
||||
return `${formatShortTime(item.attendanceClockIn)} - ${formatShortTime(item.attendanceClockOut)}`;
|
||||
}
|
||||
if (item.attendanceClockIn) {
|
||||
return `${formatShortTime(item.attendanceClockIn)} 已打卡`;
|
||||
}
|
||||
if (item.presenceStatus === "travel") {
|
||||
return "今日差旅";
|
||||
}
|
||||
@@ -311,80 +257,7 @@ function formatTeamStatusAttendance(item: PortalTeamStatusItem) {
|
||||
if (item.presenceStatus === "remote") {
|
||||
return "今日居家办公";
|
||||
}
|
||||
return "暂无打卡记录";
|
||||
}
|
||||
|
||||
function resolveClockAction(
|
||||
attendanceToday: AttendanceTodayResponse | null,
|
||||
state: {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
featureDisabled: boolean;
|
||||
pending: boolean;
|
||||
},
|
||||
) {
|
||||
if (state.pending) {
|
||||
return {
|
||||
label: "处理中...",
|
||||
description: "正在提交考勤动作",
|
||||
disabled: true,
|
||||
action: null,
|
||||
} as const;
|
||||
}
|
||||
if (state.isLoading) {
|
||||
return {
|
||||
label: "打卡读取中",
|
||||
description: "正在读取今日考勤状态",
|
||||
disabled: true,
|
||||
action: null,
|
||||
} as const;
|
||||
}
|
||||
if (state.isError && !state.featureDisabled) {
|
||||
return {
|
||||
label: "打卡不可用",
|
||||
description: "当前无法读取今日考勤状态",
|
||||
disabled: true,
|
||||
action: null,
|
||||
} as const;
|
||||
}
|
||||
if (!attendanceToday) {
|
||||
return {
|
||||
label: "今日考勤",
|
||||
description: "暂无可用考勤数据",
|
||||
disabled: true,
|
||||
action: null,
|
||||
} as const;
|
||||
}
|
||||
if (!attendanceToday.needClockIn) {
|
||||
return {
|
||||
label: "今日无需打卡",
|
||||
description: "系统已自动排除缺卡统计",
|
||||
disabled: true,
|
||||
action: null,
|
||||
} as const;
|
||||
}
|
||||
if (!attendanceToday.item?.clockInAt) {
|
||||
return {
|
||||
label: "上班打卡",
|
||||
description: "记录今日到岗时间",
|
||||
disabled: false,
|
||||
action: "clock-in",
|
||||
} as const;
|
||||
}
|
||||
if (!attendanceToday.item.clockOutAt) {
|
||||
return {
|
||||
label: "下班打卡",
|
||||
description: `已上班 ${formatShortTime(attendanceToday.item.clockInAt)}`,
|
||||
disabled: false,
|
||||
action: "clock-out",
|
||||
} as const;
|
||||
}
|
||||
return {
|
||||
label: "今日已完成",
|
||||
description: `工时 ${formatWorkMinutes(attendanceToday.item.workMinutes)}`,
|
||||
disabled: true,
|
||||
action: null,
|
||||
} as const;
|
||||
return "企微考勤见考勤页";
|
||||
}
|
||||
|
||||
function SummaryPanel({ title, items }: { title: string; items: SummaryItem[] }) {
|
||||
@@ -921,16 +794,10 @@ function ActivityAnnouncementsPanel({
|
||||
}
|
||||
|
||||
export function PortalHomePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { me, permissions } = useAuth();
|
||||
const [showWelcome, setShowWelcome] = useState(false);
|
||||
const [showGettingStarted, setShowGettingStarted] = useState(false);
|
||||
const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome() });
|
||||
const attendanceTodayQuery = useQuery({
|
||||
queryKey: ["portal-home", "attendance", "today"],
|
||||
queryFn: () => fetchMyTodayAttendance(),
|
||||
retry: (failureCount, error) => !isAttendanceFeatureDisabled(error) && failureCount < 2,
|
||||
});
|
||||
const workflowSummaryQuery = useQuery({
|
||||
enabled: hasAnyPermission(permissions, APPROVAL_PORTAL_PERMISSIONS),
|
||||
queryKey: ["workflow", "tasks", "summary", "mine"],
|
||||
@@ -980,42 +847,6 @@ export function PortalHomePage() {
|
||||
];
|
||||
const gettingStartedCompletedCount = gettingStartedItems.filter((item) => item.completed).length;
|
||||
|
||||
const attendanceClockInMutation = useMutation({
|
||||
mutationFn: () => clockInAttendance(),
|
||||
onSuccess: async (response) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home", "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: ["portal-home"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home", "attendance", "today"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
|
||||
]);
|
||||
toast.success("下班打卡成功");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
if (portalQuery.isLoading) {
|
||||
return <div className="card-std p-6 text-sm text-zinc-500">加载 Portal 中...</div>;
|
||||
}
|
||||
@@ -1052,14 +883,6 @@ export function PortalHomePage() {
|
||||
const primaryCards = pickCards(cards, portalSchema.heroCards);
|
||||
const primaryCardKeySet = primaryCards.map((card) => card.key);
|
||||
const secondaryCards = compactCards(cards, primaryCardKeySet);
|
||||
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
|
||||
const attendanceToday = attendanceTodayQuery.data ?? null;
|
||||
const clockAction = resolveClockAction(attendanceToday, {
|
||||
isLoading: attendanceTodayQuery.isLoading,
|
||||
isError: attendanceTodayQuery.isError,
|
||||
featureDisabled: attendanceFeatureDisabled,
|
||||
pending: attendanceClockInMutation.isPending || attendanceClockOutMutation.isPending,
|
||||
});
|
||||
const todoAggregateItems = buildTodoAggregateItems({ ...payload, cards });
|
||||
|
||||
function handleCloseWelcome() {
|
||||
@@ -1222,20 +1045,13 @@ export function PortalHomePage() {
|
||||
to: "/reimbursements",
|
||||
});
|
||||
}
|
||||
if (!attendanceFeatureDisabled) {
|
||||
quickActions.push({
|
||||
key: "attendance",
|
||||
icon: clockAction.action === "clock-out" ? "logout" : "schedule",
|
||||
label: clockAction.label,
|
||||
description: clockAction.description,
|
||||
disabled: clockAction.disabled,
|
||||
onClick: clockAction.action === "clock-in"
|
||||
? () => attendanceClockInMutation.mutate()
|
||||
: clockAction.action === "clock-out"
|
||||
? () => attendanceClockOutMutation.mutate()
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
quickActions.push({
|
||||
key: "attendance",
|
||||
icon: "schedule",
|
||||
label: "考勤汇总",
|
||||
description: "查看企微打卡摘要和 OA 修正",
|
||||
to: "/attendance",
|
||||
});
|
||||
if (payload.weeklyReport || payload.weeklySummary) {
|
||||
quickActions.push({
|
||||
key: "weekly",
|
||||
@@ -1262,7 +1078,7 @@ export function PortalHomePage() {
|
||||
? "配置、审计、通知。"
|
||||
: isManagerView
|
||||
? "审批、团队、周报。"
|
||||
: "待办、打卡、申请。"}
|
||||
: "待办、申请、考勤。"}
|
||||
actions={
|
||||
!payload.workspace.persisted && canSetupWorkspace ? (
|
||||
<Button asChild variant="secondary" type="button">
|
||||
|
||||
@@ -37,6 +37,9 @@ function entityTitle(entityType: string | undefined) {
|
||||
if (entityType === "reimbursement") {
|
||||
return "报销单";
|
||||
}
|
||||
if (entityType === "attendance_correction") {
|
||||
return "考勤修正";
|
||||
}
|
||||
if (entityType === "purchase_request") {
|
||||
return "采购申请";
|
||||
}
|
||||
@@ -46,6 +49,13 @@ function entityTitle(entityType: string | undefined) {
|
||||
return entityType || "业务单据";
|
||||
}
|
||||
|
||||
function correctionKindLabel(kind: string | undefined | null) {
|
||||
if (kind === "missed_punch") return "补卡";
|
||||
if (kind === "out") return "外出";
|
||||
if (kind === "overtime") return "加班";
|
||||
return "考勤修正";
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
@@ -306,6 +316,25 @@ export function WorkflowTaskDetailPage() {
|
||||
<DetailField label="附件" value={detail.leaveRequest.attachment?.originalName ?? "-"} />
|
||||
</>
|
||||
) : null}
|
||||
{detail.attendanceCorrection ? (
|
||||
<>
|
||||
<DetailField label="修正类型" value={correctionKindLabel(detail.attendanceCorrection.kind)} />
|
||||
<DetailField label="申请人" value={detail.attendanceCorrection.owner?.name ?? "-"} />
|
||||
<DetailField label="考勤日期" value={detail.attendanceCorrection.workDate ?? "-"} />
|
||||
<DetailField label="状态" value={workflowInstanceStatusLabel(detail.attendanceCorrection.status)} />
|
||||
<DetailField
|
||||
label="时间"
|
||||
value={
|
||||
detail.attendanceCorrection.kind === "missed_punch"
|
||||
? formatDateTime(detail.attendanceCorrection.missedPunchAt)
|
||||
: `${formatDateTime(detail.attendanceCorrection.startAt)} → ${formatDateTime(detail.attendanceCorrection.endAt)}`
|
||||
}
|
||||
/>
|
||||
<DetailField label="外出地点" value={detail.attendanceCorrection.locationSummary ?? "-"} />
|
||||
<DetailField label="补偿口径" value={detail.attendanceCorrection.compensationPolicy ?? "-"} />
|
||||
<DetailField label="说明" value={detail.attendanceCorrection.reason ?? "-"} />
|
||||
</>
|
||||
) : null}
|
||||
{detail.purchaseRequest ? (
|
||||
<>
|
||||
<DetailField label="采购单号" value={resolvePurchaseDisplayCode(detail.purchaseRequest)} />
|
||||
|
||||
@@ -42,6 +42,7 @@ const WORKFLOW_ENTITY_OPTIONS = [
|
||||
{ value: "all", label: "全部业务" },
|
||||
{ value: "reimbursement", label: "报销" },
|
||||
{ value: "leave", label: "请假" },
|
||||
{ value: "attendance_correction", label: "考勤" },
|
||||
{ value: "purchase_request", label: "采购" },
|
||||
{ value: "travel_request", label: "差旅" },
|
||||
] as const;
|
||||
@@ -68,6 +69,9 @@ function entityLabel(entityType: string | undefined) {
|
||||
if (entityType === "reimbursement") {
|
||||
return "报销";
|
||||
}
|
||||
if (entityType === "attendance_correction") {
|
||||
return "考勤";
|
||||
}
|
||||
if (entityType === "purchase_request") {
|
||||
return "采购";
|
||||
}
|
||||
|
||||
@@ -23,27 +23,6 @@ type FeatureFlagItem = {
|
||||
effectiveEnabled: boolean;
|
||||
};
|
||||
|
||||
type AttendanceTodayResponse = {
|
||||
workDate: string;
|
||||
needClockIn: boolean;
|
||||
exemptionReason: string | null;
|
||||
item: {
|
||||
clockInAt: string | null;
|
||||
clockOutAt: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type AttendanceTeamItem = {
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
employeeNo: string;
|
||||
departmentId: string | null;
|
||||
attendanceStatus: string;
|
||||
needClockIn: boolean;
|
||||
clockInAt: string | null;
|
||||
clockOutAt: string | null;
|
||||
};
|
||||
|
||||
type NotificationListResponse = {
|
||||
unreadCount: number;
|
||||
items: Array<{
|
||||
@@ -106,10 +85,6 @@ function formatDate(offsetDays: number) {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function toDateTimeLocal(date: string, time: string) {
|
||||
return `${date}T${time}`;
|
||||
}
|
||||
|
||||
async function readApiData<T>(response: APIResponse) {
|
||||
const payload = (await response.json()) as { data?: T; error?: { code?: string; message?: string } };
|
||||
if (!response.ok()) {
|
||||
@@ -142,22 +117,6 @@ async function ensureFeatureFlags(api: Awaited<ReturnType<typeof createAuthentic
|
||||
}
|
||||
}
|
||||
|
||||
async function findBackfillTarget(api: Awaited<ReturnType<typeof createAuthenticatedApiClient>>["api"]) {
|
||||
const offsets = [0, -1, -2, -3, -4, -5, -6, -7, 1, 2, 3, 4, 5];
|
||||
for (const offset of offsets) {
|
||||
const date = formatDate(offset);
|
||||
const payload = await apiGetData<{ date: string; items: AttendanceTeamItem[] }>(
|
||||
api,
|
||||
`/api/attendance/team?date=${date}`,
|
||||
);
|
||||
const target = payload.items.find((item) => item.needClockIn && (!item.clockInAt || !item.clockOutAt));
|
||||
if (target) {
|
||||
return { date, item: target };
|
||||
}
|
||||
}
|
||||
throw new Error("未找到可用于团队补卡的考勤缺口样本");
|
||||
}
|
||||
|
||||
async function createSubmittedReimbursement(api: AuthedApi, label: string): Promise<SubmittedReimbursementSeed> {
|
||||
const reimbursementDate = formatDate(30 + Math.floor(Math.random() * 45));
|
||||
const description = `Playwright 通知补盲 ${label}`;
|
||||
@@ -247,86 +206,36 @@ async function fetchEmployeeByKeyword(
|
||||
test.describe("R75 自动化补盲", () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("E2E R75 考勤补盲:员工今日状态 + 管理员团队补卡", async ({ page }) => {
|
||||
test("E2E R75 考勤补盲:OA 自建打卡关闭 + 统一考勤入口", async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
const adminClient = await createAuthenticatedApiClient(ADMIN);
|
||||
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
|
||||
|
||||
try {
|
||||
await ensureFeatureFlags(adminClient.api, ["attendance_v1"]);
|
||||
const backfillTarget = await findBackfillTarget(adminClient.api);
|
||||
await ensureFeatureFlags(adminClient.api, ["attendance_unified_v1"]);
|
||||
|
||||
const clockInResponse = await employeeClient.api.post("/api/attendance/clock-in", { data: {} });
|
||||
expect(clockInResponse.status()).toBe(409);
|
||||
const clockInPayload = (await clockInResponse.json()) as { error?: { details?: { featureFlag?: string } } };
|
||||
expect(clockInPayload.error?.details?.featureFlag).toBe("attendance_v1_disabled");
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
await page.goto("/attendance");
|
||||
await expect(page.getByRole("heading", { name: "考勤" })).toBeVisible();
|
||||
|
||||
const today = await apiGetData<AttendanceTodayResponse>(employeeClient.api, "/api/attendance/me/today");
|
||||
if (today.needClockIn) {
|
||||
if (!today.item?.clockInAt) {
|
||||
await page.getByRole("button", { name: "点击打上班卡" }).click();
|
||||
await expect(page.getByText("上班打卡成功")).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const latest = await apiGetData<AttendanceTodayResponse>(employeeClient.api, "/api/attendance/me/today");
|
||||
return Boolean(latest.item?.clockInAt);
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
await page.getByRole("button", { name: /点击下班|重新打下班卡/ }).click();
|
||||
await expect(page.getByText("下班打卡成功")).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const latest = await apiGetData<AttendanceTodayResponse>(employeeClient.api, "/api/attendance/me/today");
|
||||
return Boolean(latest.item?.clockOutAt);
|
||||
})
|
||||
.toBe(true);
|
||||
} else {
|
||||
await page.getByRole("button", { name: today.item.clockOutAt ? "重新打下班卡" : "点击下班" }).click();
|
||||
await expect(page.getByText("下班打卡成功")).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const latest = await apiGetData<AttendanceTodayResponse>(employeeClient.api, "/api/attendance/me/today");
|
||||
return Boolean(latest.item?.clockOutAt);
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
} else {
|
||||
await expect(page.getByText(/免打卡:/)).toBeVisible();
|
||||
}
|
||||
|
||||
await expect(page.getByText("本月打卡记录")).toBeVisible();
|
||||
await expect(page.getByText(today.workDate).first()).toBeVisible();
|
||||
await expect(page.getByText("企业微信采集打卡")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /点击打上班卡|点击下班|重新打下班卡/ })).toHaveCount(0);
|
||||
await expect(page.getByText("本月打卡记录")).toHaveCount(0);
|
||||
|
||||
await logout(page, EMPLOYEE.name);
|
||||
|
||||
await login(page, ADMIN);
|
||||
await page.goto("/attendance");
|
||||
await page.getByRole("tab", { name: "团队考勤" }).click();
|
||||
await expect(page.getByText("查看指定日期团队上下班打卡状态")).toBeVisible();
|
||||
|
||||
await page.locator('input[type="date"]').fill(backfillTarget.date);
|
||||
|
||||
const targetRow = page.getByRole("row").filter({ hasText: backfillTarget.item.employeeNo }).first();
|
||||
await expect(targetRow).toBeVisible();
|
||||
await targetRow.getByRole("button", { name: "补卡" }).click();
|
||||
|
||||
await page.getByLabel("上班时间").fill(toDateTimeLocal(backfillTarget.date, "09:00"));
|
||||
await page.getByLabel("下班时间").fill(toDateTimeLocal(backfillTarget.date, "18:00"));
|
||||
await page.getByLabel("备注").fill("Playwright R75 团队补卡");
|
||||
await page.getByRole("button", { name: "保存补卡" }).click();
|
||||
await expect(page.getByText("补卡已保存")).toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const payload = await apiGetData<{ items: AttendanceTeamItem[] }>(
|
||||
adminClient.api,
|
||||
`/api/attendance/team?date=${backfillTarget.date}`,
|
||||
);
|
||||
const item = payload.items.find((entry) => entry.employeeId === backfillTarget.item.employeeId);
|
||||
return Boolean(item?.clockInAt && item?.clockOutAt);
|
||||
})
|
||||
.toBe(true);
|
||||
await expect(page.getByRole("heading", { name: "考勤" })).toBeVisible();
|
||||
await expect(page.getByText("OA 统一处理请假、补卡、外出、加班和月度解释")).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "修正申请" })).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "团队考勤" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "保存补卡" })).toHaveCount(0);
|
||||
} finally {
|
||||
await Promise.all([employeeClient.dispose(), adminClient.dispose()]);
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ test.describe("visual shell", () => {
|
||||
await expect(page.getByRole("heading", { name: "今天" })).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-today-focus")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-action-capture")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-attendance-card")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-attendance-summary-card")).toBeVisible();
|
||||
|
||||
await attachScreenshot(page, testInfo, "mobile-today.png");
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户