From 6d13047dcb3670e5d2aed2256427c9df4cad9238 Mon Sep 17 00:00:00 2001 From: shunli Date: Tue, 10 Mar 2026 19:58:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(P20T1-4):=20=E5=B7=AE=E6=97=85=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=85=A5=E5=8F=A3=E4=B8=8E=E5=AE=A1=E6=89=B9=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E7=AB=AF=E8=81=94=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/masterData.ts | 1 + client/src/api/notificationChannels.ts | 1 + client/src/api/travelRequests.ts | 218 ++++ client/src/api/workflow.ts | 33 + client/src/auth/permissions.ts | 16 + client/src/components/navigation/NavLinks.tsx | 10 +- client/src/pages/MobileApprovalsPage.tsx | 11 + client/src/pages/NotificationChannelsPage.tsx | 3 + client/src/pages/TravelRequestsPage.tsx | 994 ++++++++++++++++++ client/src/pages/WorkflowTaskDetailPage.tsx | 23 + client/src/pages/WorkflowTasksPage.tsx | 5 +- client/src/router/index.tsx | 16 +- 12 files changed, 1325 insertions(+), 6 deletions(-) create mode 100644 client/src/api/travelRequests.ts create mode 100644 client/src/pages/TravelRequestsPage.tsx diff --git a/client/src/api/masterData.ts b/client/src/api/masterData.ts index 0c2834aa..633a41fb 100644 --- a/client/src/api/masterData.ts +++ b/client/src/api/masterData.ts @@ -4,6 +4,7 @@ export type DocumentCategoryEntityType = | "reimbursement" | "leave" | "purchase_request" + | "travel_request" | "asset_card" | "general"; diff --git a/client/src/api/notificationChannels.ts b/client/src/api/notificationChannels.ts index ce601916..ceec4b5f 100644 --- a/client/src/api/notificationChannels.ts +++ b/client/src/api/notificationChannels.ts @@ -7,6 +7,7 @@ export type NotificationDomainScope = | "reimbursement" | "leave" | "procurement" + | "travel" | "asset" | "inventory"; diff --git a/client/src/api/travelRequests.ts b/client/src/api/travelRequests.ts new file mode 100644 index 00000000..7941d0b4 --- /dev/null +++ b/client/src/api/travelRequests.ts @@ -0,0 +1,218 @@ +import { apiRequestData } from "./http"; + +export type TravelRequestStatus = "draft" | "reviewing" | "approved" | "rejected"; +export type TravelTripType = "domestic" | "international"; +export type TravelSegmentRole = "outbound" | "return" | "connection"; +export type TravelTransportType = "flight" | "train" | "car" | "bus" | "other"; + +export type TravelRequestSummary = { + id: string; + requestNo: string | null; + applicantEmployeeId: string; + submitterId: string | null; + departmentId: string; + legalEntityId: string; + costCenterId: string; + budgetPoolId: string | null; + purpose: string; + tripType: TravelTripType; + startDate: string | null; + endDate: string | null; + estimatedAmount: string; + status: TravelRequestStatus; + createdAt: string | null; + updatedAt: string | null; + applicant: { + id: string; + employeeNo: string; + name: string; + } | null; + submitter: { + id: string; + employeeNo: string; + name: string; + } | null; + department: { + id: string; + name: string; + } | null; + legalEntity: { + id: string; + code: string; + name: string; + } | null; + costCenter: { + id: string; + code: string; + name: string; + legalEntityId: string; + } | null; + budgetPool: { + id: string; + code: string; + name: string; + } | null; +}; + +export type TravelRequestSegment = { + id: string; + sortOrder: number; + segmentRole: TravelSegmentRole; + transportType: TravelTransportType; + fromCity: string; + toCity: string; + departAt: string | null; + arriveAt: string | null; + createdAt: string | null; + updatedAt: string | null; +}; + +export type TravelOrderLink = { + id: string; + source: string; + externalOrderNo: string; + employeeId: string; + linkedReimbursementId: string | null; + occurredAt: string | null; + amount: string | null; + currency: string; + status: string; + createdAt: string | null; + updatedAt: string | null; +}; + +export type TravelRequestDetail = TravelRequestSummary & { + segments: TravelRequestSegment[]; + travelOrders: TravelOrderLink[]; + pendingWorkflow: { + instanceId: string; + currentStatus: string; + currentStepId: string; + task: { + id: string; + stepId: string; + stepName: string; + assigneeId: string | null; + assigneeRole: string | null; + } | null; + } | null; + permissions: { + canEditDraft: boolean; + canSubmit: boolean; + canWithdraw: boolean; + canManage: boolean; + }; +}; + +export type TravelRequestListResponse = { + page: number; + pageSize: number; + total: number; + mine: boolean; + items: TravelRequestSummary[]; +}; + +export type TravelRequestListQuery = { + status?: TravelRequestStatus; + mine?: boolean; + tripType?: TravelTripType; + legalEntityId?: string; + costCenterId?: string; + page?: number; + pageSize?: number; +}; + +export type TravelRequestSegmentPayload = { + segmentRole: TravelSegmentRole; + transportType: TravelTransportType; + fromCity: string; + toCity: string; + departAt: string; + arriveAt: string; +}; + +export type TravelRequestPayload = { + purpose: string; + tripType: TravelTripType; + startDate: string; + endDate: string; + legalEntityId: string; + costCenterId: string; + estimatedAmount: string; + segments: TravelRequestSegmentPayload[]; +}; + +export type TravelRequestSubmitResponse = { + travelRequestId: string; + requestNo: string; + status: "reviewing"; + workflowInstanceId: string; + currentTaskId: string; + budgetPoolId: string | null; + budgetReservation: { + applied: boolean; + reservedAmount?: string; + items?: Array<{ budgetPoolId: string; amount: string }>; + }; +}; + +export type TravelRequestWithdrawResponse = { + travelRequestId: string; + status: "draft"; + outcome: "withdrawn"; + workflowInstanceId: string | null; + budgetRelease: { + applied: boolean; + items?: Array<{ budgetPoolId: string; amount: string }>; + }; +}; + +function toQueryString(query: Record) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value == null || value === "") { + continue; + } + params.set(key, typeof value === "boolean" ? (value ? "1" : "0") : String(value)); + } + const encoded = params.toString(); + return encoded ? `?${encoded}` : ""; +} + +export async function fetchTravelRequests(query: TravelRequestListQuery = {}) { + return apiRequestData( + `/api/travel-requests${toQueryString(query as Record)}`, + ); +} + +export async function createTravelRequest(payload: TravelRequestPayload) { + return apiRequestData("/api/travel-requests", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function fetchTravelRequestDetail(travelRequestId: string) { + return apiRequestData(`/api/travel-requests/${travelRequestId}`); +} + +export async function updateTravelRequest(travelRequestId: string, payload: Partial) { + return apiRequestData(`/api/travel-requests/${travelRequestId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + +export async function submitTravelRequest(travelRequestId: string) { + return apiRequestData(`/api/travel-requests/${travelRequestId}/submit`, { + method: "POST", + body: JSON.stringify({}), + }); +} + +export async function withdrawTravelRequest(travelRequestId: string) { + return apiRequestData(`/api/travel-requests/${travelRequestId}/withdraw`, { + method: "POST", + body: JSON.stringify({}), + }); +} diff --git a/client/src/api/workflow.ts b/client/src/api/workflow.ts index 07059f9f..113f3416 100644 --- a/client/src/api/workflow.ts +++ b/client/src/api/workflow.ts @@ -136,6 +136,39 @@ export type WorkflowTaskDetail = { name: string; } | null; } | null; + travelRequest: { + id: string; + requestNo: string | null; + status: string; + purpose: string; + tripType: string; + startDate: string | null; + endDate: string | null; + estimatedAmount: string | null; + applicant: { + id: string; + employeeNo: string; + name: string; + } | null; + legalEntity: { + id: string; + code: string; + name: string; + } | null; + costCenter: { + id: string; + code: string; + name: string; + } | null; + segments: Array<{ + id: string; + sortOrder: number; + segmentRole: string; + transportType: string; + fromCity: string; + toCity: string; + }>; + } | null; allowedActions: Array<"approve" | "reject" | "return" | "transfer">; history: WorkflowHistoryItem[]; }; diff --git a/client/src/auth/permissions.ts b/client/src/auth/permissions.ts index 7d608e2c..65f4fb95 100644 --- a/client/src/auth/permissions.ts +++ b/client/src/auth/permissions.ts @@ -22,6 +22,12 @@ const PROCUREMENT_SELF_SERVICE_PERMISSIONS = [ "procurement.submit", ] as const; +const TRAVEL_SELF_SERVICE_PERMISSIONS = [ + "travel.create", + "travel.view_own", + "travel.submit", +] as const; + const ASSET_SELF_SERVICE_PERMISSIONS = ["asset.view_own"] as const; const MASTERDATA_VIEW_PERMISSIONS = ["masterdata.view"] as const; @@ -30,6 +36,7 @@ const MASTERDATA_MANAGE_PERMISSIONS = ["masterdata.manage"] as const; const EMPLOYEE_BASE_PERMISSIONS = [ ...EXPENSE_SELF_SERVICE_PERMISSIONS, ...LEAVE_SELF_SERVICE_PERMISSIONS, + ...TRAVEL_SELF_SERVICE_PERMISSIONS, ] as const; const ROLE_PERMISSIONS: Record = { @@ -47,6 +54,8 @@ const ROLE_PERMISSIONS: Record = { "leave.approve", "procurement.approve", "procurement.view_all", + "travel.approve", + "travel.view_all", "asset.view_all", "report.dashboard.view", "budget.view", @@ -63,6 +72,8 @@ const ROLE_PERMISSIONS: Record = { "leave.view_all", "procurement.approve", "procurement.view_all", + "travel.approve", + "travel.view_all", "asset.view_all", "report.dashboard.view", "budget.view", @@ -78,6 +89,7 @@ const ROLE_PERMISSIONS: Record = { "expense.finance.pay", "expense.view_all", "procurement.view_all", + "travel.view_all", "asset.view_all", ], finance_mgr: [ @@ -97,6 +109,8 @@ const ROLE_PERMISSIONS: Record = { "expense.finance.override_duplicate", "procurement.view_all", "procurement.manage", + "travel.view_all", + "travel.manage", "asset.view_all", "asset.manage", "asset.inventory", @@ -108,6 +122,8 @@ const ROLE_PERMISSIONS: Record = { ...EMPLOYEE_BASE_PERMISSIONS, "procurement.view_all", "procurement.manage", + "travel.view_all", + "travel.manage", "asset.view_all", "asset.manage", "asset.transfer", diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index e9c7c2f9..e1735cc9 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -30,11 +30,17 @@ export const NAV_ITEMS: NavItem[] = [ icon: "shopping_cart", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"], }, + { + to: "/travel-requests", + label: "差旅申请", + icon: "flight", + anyOf: ["travel.create", "travel.view_own", "travel.view_all", "travel.manage"], + }, { to: "/workflow/tasks", label: "待我处理", icon: "fact_check", - anyOf: ["expense.approve", "leave.approve", "procurement.approve"], + anyOf: ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"], }, { to: "/assets", @@ -64,7 +70,7 @@ export const NAV_ITEMS: NavItem[] = [ to: "/mobile/approvals", label: "移动审批", icon: "mobile_friendly", - anyOf: ["expense.approve", "leave.approve", "procurement.approve"], + anyOf: ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"], }, { to: "/budgets", diff --git a/client/src/pages/MobileApprovalsPage.tsx b/client/src/pages/MobileApprovalsPage.tsx index 3bbbffbd..d4512267 100644 --- a/client/src/pages/MobileApprovalsPage.tsx +++ b/client/src/pages/MobileApprovalsPage.tsx @@ -29,6 +29,9 @@ function entityLabel(entityType: string | undefined) { if (entityType === "purchase_request") { return "采购"; } + if (entityType === "travel_request") { + return "差旅"; + } return entityType || "未知"; } @@ -189,6 +192,14 @@ export function MobileApprovalsPage() {
金额:{selectedTask.purchaseRequest.totalAmount ?? "-"}
) : null} + {selectedTask.travelRequest ? ( + <> +
差旅单号:{selectedTask.travelRequest.requestNo ?? selectedTask.travelRequest.id}
+
申请人:{selectedTask.travelRequest.applicant?.name ?? "-"}
+
出差类型:{selectedTask.travelRequest.tripType === "international" ? "国际" : "国内"}
+
预估金额:{selectedTask.travelRequest.estimatedAmount ?? "-"}
+ + ) : null}