From 4ba311199d1eb3ef86147399facfa6e34b53cf78 Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 7 Mar 2026 08:11:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(P15B-1,P15B-6):=20=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E8=AF=B7=E5=81=87V1=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/errorMessages.ts | 36 + client/src/api/leave.ts | 204 +++++ client/src/api/workflow.ts | 25 + client/src/auth/permissions.ts | 35 +- client/src/components/navigation/NavLinks.tsx | 6 + client/src/pages/LeaveRequestPage.tsx | 536 +++++++++++++ client/src/pages/MobileApprovalsPage.tsx | 97 ++- client/src/pages/WorkflowTaskDetailPage.tsx | 120 +-- client/src/pages/WorkflowTasksPage.tsx | 31 +- client/src/router/index.tsx | 10 + docs/feature-matrix.md | 25 +- .../2026-03-06_14-30-00-p15b-l5-leave-v1.csv | 20 +- ...-06_20-00-00-p15-p18-roadmap-preflight.csv | 20 +- scripts/validate-feature-matrix.py | 2 + .../migration.sql | 121 +++ server/prisma/schema.prisma | 315 +++++--- server/src/app.js | 2 + server/src/routes/leave-types.js | 314 ++++++++ server/src/routes/leave.js | 724 ++++++++++++++++-- server/src/routes/workflow.js | 169 ++++ server/src/scripts/seed-config.js | 95 +++ server/src/services/leave.js | 496 ++++++++++++ server/tests/unit/services/leave.test.js | 107 +++ 23 files changed, 3202 insertions(+), 308 deletions(-) create mode 100644 client/src/api/leave.ts create mode 100644 client/src/pages/LeaveRequestPage.tsx create mode 100644 server/prisma/migrations/20260306233806_p15b_leave_v1_models/migration.sql create mode 100644 server/src/routes/leave-types.js create mode 100644 server/src/services/leave.js create mode 100644 server/tests/unit/services/leave.test.js diff --git a/client/src/api/errorMessages.ts b/client/src/api/errorMessages.ts index 5234c1bf..d8691c2f 100644 --- a/client/src/api/errorMessages.ts +++ b/client/src/api/errorMessages.ts @@ -136,6 +136,42 @@ export function mapApiErrorMessage( return "当前报销金额超出预算,系统已阻断提交。"; } + if (details?.leave === "conflict_range") { + return "当前时间段与已有请假记录冲突,请调整后再提交。"; + } + + if (details?.leave === "insufficient_balance") { + return "当前假种可用额度不足,无法提交。"; + } + + if (details?.leave === "invalid_range") { + return "请假开始/结束日期与半天时段不合法。"; + } + + if (details?.leave === "missing_reserve_ledger") { + return "请假冻结账本状态异常,请联系管理员处理。"; + } + + if (details?.file === "required") { + return "当前假种要求上传附件后才能提交。"; + } + + if (details?.file === "unsupported_mime_type") { + return "附件类型不被当前假种接受,请重新选择。"; + } + + if (details?.withdraw === "no_pending_instance") { + return "当前请假单不存在可撤回的审批实例。"; + } + + if (details?.withdraw === "already_processed_by_approver") { + return "审批人已处理该请假单,当前不能撤回。"; + } + + if (details?.leaveTypeId === "missing_active_policy") { + return "当前假种未配置可用策略,请联系 HR。"; + } + if (details?.integration === "erp_sync_disabled") { return "ERP 同步功能未开启。"; } diff --git a/client/src/api/leave.ts b/client/src/api/leave.ts new file mode 100644 index 00000000..90f41eee --- /dev/null +++ b/client/src/api/leave.ts @@ -0,0 +1,204 @@ +import { apiRequestData } from "./http"; + +export type LeaveSession = "am" | "pm"; +export type LeaveRequestStatus = "draft" | "reviewing" | "approved" | "rejected" | "cancelled"; + +export type LeaveType = { + id: string; + code: string; + name: string; + description: string | null; + enabled: boolean; + requiresAttachment: boolean; + attachmentMimeTypes: string[]; + sortOrder: number; + policy: { + id: string; + annualQuotaDays: number; + enabled: boolean; + description: string | null; + } | null; + createdAt: string | null; + updatedAt: string | null; +}; + +export type LeaveBalanceItem = { + leaveType: LeaveType; + policy: { + id: string; + annualQuotaDays: number; + enabled: boolean; + description: string | null; + }; + granted: number; + adjusted: number; + reserved: number; + used: number; + available: number; + periodStart: string | null; + periodEnd: string | null; +}; + +export type LeaveRequestItem = { + id: string; + ownerId: string; + departmentId: string; + status: LeaveRequestStatus; + startDate: string; + endDate: string; + startSession: LeaveSession; + endSession: LeaveSession; + requestedDays: number; + reason: string | null; + attachment: { + originalName: string | null; + mimeType: string | null; + } | null; + leaveType: { + id: string; + code: string; + name: string; + requiresAttachment: boolean; + attachmentMimeTypes: string[]; + } | null; + owner: { + id: string; + employeeNo: string; + name: string; + } | null; + createdAt: string | null; + updatedAt: string | null; +}; + +export type LeaveRequestListResponse = { + page: number; + pageSize: number; + total: number; + scope: "own" | "all"; + items: LeaveRequestItem[]; +}; + +export type LeaveBalanceResponse = { + employeeId: string; + items: LeaveBalanceItem[]; +}; + +export type LeaveListQuery = { + status?: LeaveRequestStatus; + scope?: "own" | "all"; + page?: number; + pageSize?: number; +}; + +function toQueryString(query: Record) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value == null || value === "") { + continue; + } + params.set(key, String(value)); + } + const encoded = params.toString(); + return encoded ? `?${encoded}` : ""; +} + +export async function fetchLeaveTypes(params?: { enabled?: boolean }) { + return apiRequestData<{ items: LeaveType[] }>( + `/api/leave-types${toQueryString({ enabled: params?.enabled == null ? undefined : String(params.enabled) })}`, + ); +} + +export async function createLeaveType(payload: { + code: string; + name: string; + description?: string | null; + enabled?: boolean; + requiresAttachment?: boolean; + attachmentMimeTypes?: string[]; + sortOrder?: number; + annualQuotaDays: number; + policyDescription?: string | null; +}) { + return apiRequestData("/api/leave-types", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function patchLeaveType( + leaveTypeId: string, + payload: Partial<{ + code: string; + name: string; + description: string | null; + enabled: boolean; + requiresAttachment: boolean; + attachmentMimeTypes: string[]; + sortOrder: number; + annualQuotaDays: number; + policyDescription: string | null; + }>, +) { + return apiRequestData(`/api/leave-types/${leaveTypeId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + +export async function fetchLeaveBalances(employeeId?: string) { + return apiRequestData( + `/api/leave-balances${toQueryString({ employeeId })}`, + ); +} + +export async function fetchLeaveRequests(query: LeaveListQuery = {}) { + return apiRequestData( + `/api/leave-requests${toQueryString(query as Record)}`, + ); +} + +export async function fetchLeaveRequestDetail(leaveRequestId: string) { + return apiRequestData(`/api/leave-requests/${leaveRequestId}`); +} + +export async function createLeaveRequest(payload: { + leaveTypeId: string; + startDate: string; + endDate: string; + startSession: LeaveSession; + endSession: LeaveSession; + reason?: string; + file?: File | null; +}) { + const formData = new FormData(); + formData.append("leaveTypeId", payload.leaveTypeId); + formData.append("startDate", payload.startDate); + formData.append("endDate", payload.endDate); + formData.append("startSession", payload.startSession); + formData.append("endSession", payload.endSession); + if (payload.reason?.trim()) { + formData.append("reason", payload.reason.trim()); + } + if (payload.file) { + formData.append("file", payload.file); + } + + return apiRequestData("/api/leave-requests", { + method: "POST", + body: formData, + }); +} + +export async function submitLeaveRequest(leaveRequestId: string) { + return apiRequestData>(`/api/leave-requests/${leaveRequestId}/submit`, { + method: "POST", + body: JSON.stringify({}), + }); +} + +export async function withdrawLeaveRequest(leaveRequestId: string, comment?: string) { + return apiRequestData>(`/api/leave-requests/${leaveRequestId}/withdraw`, { + method: "POST", + body: JSON.stringify({ comment }), + }); +} diff --git a/client/src/api/workflow.ts b/client/src/api/workflow.ts index bf7e4fe7..be41f413 100644 --- a/client/src/api/workflow.ts +++ b/client/src/api/workflow.ts @@ -53,6 +53,7 @@ export type WorkflowTaskDetail = { }; workflowInstance: { id: string; + entityType: string; currentStatus: string; currentStepId: string; startedAt: string | null; @@ -76,6 +77,30 @@ export type WorkflowTaskDetail = { name: string; } | null; } | null; + leaveRequest: { + id: string; + status: string; + startDate: string | null; + endDate: string | null; + startSession: "am" | "pm"; + endSession: "am" | "pm"; + requestedDays: number; + reason: string | null; + attachment: { + originalName: string | null; + mimeType: string | null; + } | null; + leaveType: { + id: string; + code: string; + name: string; + } | null; + owner: { + id: string; + employeeNo: string; + name: string; + } | null; + } | null; allowedActions: Array<"approve" | "reject" | "return" | "transfer">; history: WorkflowHistoryItem[]; }; diff --git a/client/src/auth/permissions.ts b/client/src/auth/permissions.ts index 39f08311..640f0bfa 100644 --- a/client/src/auth/permissions.ts +++ b/client/src/auth/permissions.ts @@ -1,4 +1,4 @@ -const EMPLOYEE_BASE_PERMISSIONS = [ +const EXPENSE_SELF_SERVICE_PERMISSIONS = [ "expense.create", "expense.view_own", "expense.edit_own", @@ -10,6 +10,17 @@ const EMPLOYEE_BASE_PERMISSIONS = [ "expense.proxy_manage", ] as const; +const LEAVE_SELF_SERVICE_PERMISSIONS = [ + "leave.create", + "leave.view_own", + "leave.submit", +] as const; + +const EMPLOYEE_BASE_PERMISSIONS = [ + ...EXPENSE_SELF_SERVICE_PERMISSIONS, + ...LEAVE_SELF_SERVICE_PERMISSIONS, +] as const; + const ROLE_PERMISSIONS: Record = { employee: EMPLOYEE_BASE_PERMISSIONS, manager: [ @@ -17,6 +28,7 @@ const ROLE_PERMISSIONS: Record = { "expense.approve", "expense.transfer", "expense.view_all", + "leave.approve", "report.dashboard.view", "budget.view", "budget.manage", @@ -26,6 +38,8 @@ const ROLE_PERMISSIONS: Record = { "expense.approve", "expense.transfer", "expense.view_all", + "leave.approve", + "leave.view_all", "report.dashboard.view", "budget.view", ], @@ -57,16 +71,17 @@ const ROLE_PERMISSIONS: Record = { ], administration: [ "expense.proxy_submit", - "expense.create", - "expense.view_own", - "expense.edit_own", - "expense.delete_own", - "expense.submit", - "expense.withdraw", - "invoice.upload", - "invoice.view_own", + ...EMPLOYEE_BASE_PERMISSIONS, + ], + hr: [ + ...LEAVE_SELF_SERVICE_PERMISSIONS, + "leave.view_all", + "leave.manage", + "system.users", + "system.users_sensitive", + "expense.view_all", + "report.dashboard.view", ], - hr: ["system.users", "system.users_sensitive", "expense.view_all", "report.dashboard.view"], admin: ["*"], }; diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index cf3d0d5a..c4f9089b 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -18,6 +18,12 @@ export const NAV_ITEMS: NavItem[] = [ icon: "request_quote", anyOf: ["expense.view_own", "expense.view_all"], }, + { + to: "/leave-requests", + label: "请假申请", + icon: "event_available", + anyOf: ["leave.view_own", "leave.view_all"], + }, { to: "/workflow/tasks", label: "待我处理", diff --git a/client/src/pages/LeaveRequestPage.tsx b/client/src/pages/LeaveRequestPage.tsx new file mode 100644 index 00000000..2b00f9ff --- /dev/null +++ b/client/src/pages/LeaveRequestPage.tsx @@ -0,0 +1,536 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { mapApiErrorMessage } from "../api/errorMessages"; +import { + createLeaveRequest, + fetchLeaveBalances, + fetchLeaveRequests, + fetchLeaveTypes, + submitLeaveRequest, + withdrawLeaveRequest, + type LeaveRequestStatus, + type LeaveSession, +} from "../api/leave"; +import { isApiError } from "../api/types"; +import { useAuth } from "../auth/AuthContext"; +import { hasPermission } from "../auth/permissions"; +import { CardSkeleton } from "../components/common/CardSkeleton"; +import { ConfirmDialog } from "../components/common/ConfirmDialog"; +import { PageHeader } from "../components/common/PageHeader"; +import { Pagination } from "../components/common/Pagination"; +import { StatusBadge } from "../components/common/StatusBadge"; +import { TableSkeleton } from "../components/common/TableSkeleton"; +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 { Textarea } from "../components/ui/textarea"; + +const PAGE_SIZE = 10; + +function resolveError(error: unknown) { + if (isApiError(error)) { + return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; + } + if (error instanceof Error) { + return error.message; + } + return "操作失败,请稍后重试。"; +} + +function calculateRequestedDaysPreview( + startDate: string, + endDate: string, + startSession: LeaveSession, + endSession: LeaveSession, +) { + if (!startDate || !endDate) { + return null; + } + + const start = new Date(`${startDate}T00:00:00.000Z`); + const end = new Date(`${endDate}T00:00:00.000Z`); + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + return null; + } + + const dayIndex = (value: Date) => Math.floor(value.getTime() / 86400000); + const startSlot = dayIndex(start) * 2 + (startSession === "pm" ? 1 : 0); + const endSlot = dayIndex(end) * 2 + (endSession === "pm" ? 1 : 0); + if (endSlot < startSlot) { + return null; + } + + return Number((((endSlot - startSlot + 1) / 2)).toFixed(1)); +} + +function formatRangeLabel(item: { + startDate: string; + endDate: string; + startSession: LeaveSession; + endSession: LeaveSession; +}) { + const sessionLabel = (session: LeaveSession) => (session === "am" ? "上午" : "下午"); + if (item.startDate === item.endDate && item.startSession === item.endSession) { + return `${item.startDate} ${sessionLabel(item.startSession)}`; + } + return `${item.startDate} ${sessionLabel(item.startSession)} → ${item.endDate} ${sessionLabel(item.endSession)}`; +} + +export function LeaveRequestPage() { + const queryClient = useQueryClient(); + const { me, permissions } = useAuth(); + + const canViewAll = hasPermission(permissions, "leave.view_all") || hasPermission(permissions, "leave.manage"); + + const [statusFilter, setStatusFilter] = useState(""); + const [scope, setScope] = useState<"own" | "all">("own"); + const [page, setPage] = useState(1); + + const [leaveTypeId, setLeaveTypeId] = useState(""); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [startSession, setStartSession] = useState("am"); + const [endSession, setEndSession] = useState("pm"); + const [reason, setReason] = useState(""); + const [attachmentFile, setAttachmentFile] = useState(null); + const [formError, setFormError] = useState(""); + const [withdrawTarget, setWithdrawTarget] = useState<{ id: string; label: string } | null>(null); + + const requestedDaysPreview = useMemo( + () => calculateRequestedDaysPreview(startDate, endDate, startSession, endSession), + [endDate, endSession, startDate, startSession], + ); + + const leaveTypesQuery = useQuery({ + queryKey: ["leave", "types"], + queryFn: () => fetchLeaveTypes({ enabled: true }), + }); + + const balancesQuery = useQuery({ + queryKey: ["leave", "balances", me?.id], + enabled: Boolean(me?.id), + queryFn: () => fetchLeaveBalances(), + }); + + const requestsQuery = useQuery({ + queryKey: ["leave", "requests", statusFilter, scope, page], + queryFn: () => + fetchLeaveRequests({ + status: statusFilter || undefined, + scope: canViewAll ? scope : "own", + page, + pageSize: PAGE_SIZE, + }), + }); + + const selectedLeaveType = useMemo(() => { + return (leaveTypesQuery.data?.items ?? []).find((item) => item.id === leaveTypeId) ?? null; + }, [leaveTypeId, leaveTypesQuery.data?.items]); + + const createMutation = useMutation({ + mutationFn: async () => { + if (!leaveTypeId) { + throw new Error("请选择假期类型"); + } + if (!startDate || !endDate) { + throw new Error("请选择开始和结束日期"); + } + if (!selectedLeaveType) { + throw new Error("假期类型不存在或已失效"); + } + if (requestedDaysPreview == null || requestedDaysPreview <= 0) { + throw new Error("请检查开始/结束日期与半天时段"); + } + if (selectedLeaveType.requiresAttachment && !attachmentFile) { + throw new Error("当前假种必须上传附件"); + } + if ( + attachmentFile && + selectedLeaveType.attachmentMimeTypes.length > 0 && + !selectedLeaveType.attachmentMimeTypes.includes(attachmentFile.type) + ) { + throw new Error("附件类型不符合当前假种要求"); + } + + return createLeaveRequest({ + leaveTypeId, + startDate, + endDate, + startSession, + endSession, + reason, + file: attachmentFile, + }); + }, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["leave", "requests"] }), + queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }), + ]); + setLeaveTypeId(""); + setStartDate(""); + setEndDate(""); + setStartSession("am"); + setEndSession("pm"); + setReason(""); + setAttachmentFile(null); + setFormError(""); + toast.success("请假草稿已创建"); + }, + onError: (error) => { + const message = resolveError(error); + setFormError(message); + toast.error(message); + }, + }); + + const submitMutation = useMutation({ + mutationFn: (leaveRequestId: string) => submitLeaveRequest(leaveRequestId), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["leave", "requests"] }), + queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }), + ]); + toast.success("请假申请已提交"); + }, + onError: (error) => { + toast.error(resolveError(error)); + }, + }); + + const withdrawMutation = useMutation({ + mutationFn: (leaveRequestId: string) => withdrawLeaveRequest(leaveRequestId), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["leave", "requests"] }), + queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }), + ]); + toast.success("请假申请已撤回"); + setWithdrawTarget(null); + }, + onError: (error) => { + toast.error(resolveError(error)); + }, + }); + + const totalPages = Math.max(1, Math.ceil((requestsQuery.data?.total ?? 0) / PAGE_SIZE)); + + return ( +
+ + +
+

额度概览

+ {balancesQuery.isLoading ? ( +
+ + + +
+ ) : null} + + {balancesQuery.isError ? ( +

+ {resolveError(balancesQuery.error)} +

+ ) : null} + + {!balancesQuery.isLoading && !balancesQuery.isError ? ( +
+ {(balancesQuery.data?.items ?? []).length ? ( + balancesQuery.data?.items.map((item) => ( +
+
+
+

{item.leaveType.name}

+

{item.periodStart} ~ {item.periodEnd}

+
+ +
+
+
+

可用

+

{item.available}

+
+
+

已冻结

+

{item.reserved}

+
+
+

已使用

+

{item.used}

+
+
+

授予额度

+

{item.policy.annualQuotaDays}

+
+
+
+ )) + ) : ( +
+ 暂无可用假种,请联系 HR 初始化请假类型与策略。 +
+ )} +
+ ) : null} +
+ +
+

新建请假草稿

+ + {formError ? ( +

+ {formError} +

+ ) : null} + +
+
+ + +
+ +
+ + setStartDate(event.target.value)} /> +
+ +
+ + +
+ +
+ + setEndDate(event.target.value)} /> +
+ +
+ + +
+ +
+ + { + const nextFile = event.target.files?.[0] ?? null; + setAttachmentFile(nextFile); + }} + /> +

+ {selectedLeaveType?.requiresAttachment + ? `当前假种要求上传附件(允许:${selectedLeaveType.attachmentMimeTypes.join(", ") || "默认文档/图片"})` + : "附件为可选项"} +

+
+
+ +
+ +