feat(P20T1-4): 差旅前端入口与审批移动端联动
这个提交包含在:
@@ -4,6 +4,7 @@ export type DocumentCategoryEntityType =
|
|||||||
| "reimbursement"
|
| "reimbursement"
|
||||||
| "leave"
|
| "leave"
|
||||||
| "purchase_request"
|
| "purchase_request"
|
||||||
|
| "travel_request"
|
||||||
| "asset_card"
|
| "asset_card"
|
||||||
| "general";
|
| "general";
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type NotificationDomainScope =
|
|||||||
| "reimbursement"
|
| "reimbursement"
|
||||||
| "leave"
|
| "leave"
|
||||||
| "procurement"
|
| "procurement"
|
||||||
|
| "travel"
|
||||||
| "asset"
|
| "asset"
|
||||||
| "inventory";
|
| "inventory";
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, string | number | boolean | undefined>) {
|
||||||
|
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<TravelRequestListResponse>(
|
||||||
|
`/api/travel-requests${toQueryString(query as Record<string, string | number | boolean | undefined>)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTravelRequest(payload: TravelRequestPayload) {
|
||||||
|
return apiRequestData<TravelRequestDetail>("/api/travel-requests", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTravelRequestDetail(travelRequestId: string) {
|
||||||
|
return apiRequestData<TravelRequestDetail>(`/api/travel-requests/${travelRequestId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTravelRequest(travelRequestId: string, payload: Partial<TravelRequestPayload>) {
|
||||||
|
return apiRequestData<TravelRequestDetail>(`/api/travel-requests/${travelRequestId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitTravelRequest(travelRequestId: string) {
|
||||||
|
return apiRequestData<TravelRequestSubmitResponse>(`/api/travel-requests/${travelRequestId}/submit`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withdrawTravelRequest(travelRequestId: string) {
|
||||||
|
return apiRequestData<TravelRequestWithdrawResponse>(`/api/travel-requests/${travelRequestId}/withdraw`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -136,6 +136,39 @@ export type WorkflowTaskDetail = {
|
|||||||
name: string;
|
name: string;
|
||||||
} | null;
|
} | null;
|
||||||
} | 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">;
|
allowedActions: Array<"approve" | "reject" | "return" | "transfer">;
|
||||||
history: WorkflowHistoryItem[];
|
history: WorkflowHistoryItem[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ const PROCUREMENT_SELF_SERVICE_PERMISSIONS = [
|
|||||||
"procurement.submit",
|
"procurement.submit",
|
||||||
] as const;
|
] 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 ASSET_SELF_SERVICE_PERMISSIONS = ["asset.view_own"] as const;
|
||||||
|
|
||||||
const MASTERDATA_VIEW_PERMISSIONS = ["masterdata.view"] 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 = [
|
const EMPLOYEE_BASE_PERMISSIONS = [
|
||||||
...EXPENSE_SELF_SERVICE_PERMISSIONS,
|
...EXPENSE_SELF_SERVICE_PERMISSIONS,
|
||||||
...LEAVE_SELF_SERVICE_PERMISSIONS,
|
...LEAVE_SELF_SERVICE_PERMISSIONS,
|
||||||
|
...TRAVEL_SELF_SERVICE_PERMISSIONS,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||||
@@ -47,6 +54,8 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
|||||||
"leave.approve",
|
"leave.approve",
|
||||||
"procurement.approve",
|
"procurement.approve",
|
||||||
"procurement.view_all",
|
"procurement.view_all",
|
||||||
|
"travel.approve",
|
||||||
|
"travel.view_all",
|
||||||
"asset.view_all",
|
"asset.view_all",
|
||||||
"report.dashboard.view",
|
"report.dashboard.view",
|
||||||
"budget.view",
|
"budget.view",
|
||||||
@@ -63,6 +72,8 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
|||||||
"leave.view_all",
|
"leave.view_all",
|
||||||
"procurement.approve",
|
"procurement.approve",
|
||||||
"procurement.view_all",
|
"procurement.view_all",
|
||||||
|
"travel.approve",
|
||||||
|
"travel.view_all",
|
||||||
"asset.view_all",
|
"asset.view_all",
|
||||||
"report.dashboard.view",
|
"report.dashboard.view",
|
||||||
"budget.view",
|
"budget.view",
|
||||||
@@ -78,6 +89,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
|||||||
"expense.finance.pay",
|
"expense.finance.pay",
|
||||||
"expense.view_all",
|
"expense.view_all",
|
||||||
"procurement.view_all",
|
"procurement.view_all",
|
||||||
|
"travel.view_all",
|
||||||
"asset.view_all",
|
"asset.view_all",
|
||||||
],
|
],
|
||||||
finance_mgr: [
|
finance_mgr: [
|
||||||
@@ -97,6 +109,8 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
|||||||
"expense.finance.override_duplicate",
|
"expense.finance.override_duplicate",
|
||||||
"procurement.view_all",
|
"procurement.view_all",
|
||||||
"procurement.manage",
|
"procurement.manage",
|
||||||
|
"travel.view_all",
|
||||||
|
"travel.manage",
|
||||||
"asset.view_all",
|
"asset.view_all",
|
||||||
"asset.manage",
|
"asset.manage",
|
||||||
"asset.inventory",
|
"asset.inventory",
|
||||||
@@ -108,6 +122,8 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
|||||||
...EMPLOYEE_BASE_PERMISSIONS,
|
...EMPLOYEE_BASE_PERMISSIONS,
|
||||||
"procurement.view_all",
|
"procurement.view_all",
|
||||||
"procurement.manage",
|
"procurement.manage",
|
||||||
|
"travel.view_all",
|
||||||
|
"travel.manage",
|
||||||
"asset.view_all",
|
"asset.view_all",
|
||||||
"asset.manage",
|
"asset.manage",
|
||||||
"asset.transfer",
|
"asset.transfer",
|
||||||
|
|||||||
@@ -30,11 +30,17 @@ export const NAV_ITEMS: NavItem[] = [
|
|||||||
icon: "shopping_cart",
|
icon: "shopping_cart",
|
||||||
anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"],
|
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",
|
to: "/workflow/tasks",
|
||||||
label: "待我处理",
|
label: "待我处理",
|
||||||
icon: "fact_check",
|
icon: "fact_check",
|
||||||
anyOf: ["expense.approve", "leave.approve", "procurement.approve"],
|
anyOf: ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: "/assets",
|
to: "/assets",
|
||||||
@@ -64,7 +70,7 @@ export const NAV_ITEMS: NavItem[] = [
|
|||||||
to: "/mobile/approvals",
|
to: "/mobile/approvals",
|
||||||
label: "移动审批",
|
label: "移动审批",
|
||||||
icon: "mobile_friendly",
|
icon: "mobile_friendly",
|
||||||
anyOf: ["expense.approve", "leave.approve", "procurement.approve"],
|
anyOf: ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: "/budgets",
|
to: "/budgets",
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ function entityLabel(entityType: string | undefined) {
|
|||||||
if (entityType === "purchase_request") {
|
if (entityType === "purchase_request") {
|
||||||
return "采购";
|
return "采购";
|
||||||
}
|
}
|
||||||
|
if (entityType === "travel_request") {
|
||||||
|
return "差旅";
|
||||||
|
}
|
||||||
return entityType || "未知";
|
return entityType || "未知";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +192,14 @@ export function MobileApprovalsPage() {
|
|||||||
<div>金额:{selectedTask.purchaseRequest.totalAmount ?? "-"}</div>
|
<div>金额:{selectedTask.purchaseRequest.totalAmount ?? "-"}</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
{selectedTask.travelRequest ? (
|
||||||
|
<>
|
||||||
|
<div>差旅单号:{selectedTask.travelRequest.requestNo ?? selectedTask.travelRequest.id}</div>
|
||||||
|
<div>申请人:{selectedTask.travelRequest.applicant?.name ?? "-"}</div>
|
||||||
|
<div>出差类型:{selectedTask.travelRequest.tripType === "international" ? "国际" : "国内"}</div>
|
||||||
|
<div>预估金额:{selectedTask.travelRequest.estimatedAmount ?? "-"}</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ const DOMAIN_SCOPE_OPTIONS = [
|
|||||||
{ value: "reimbursement", label: "报销" },
|
{ value: "reimbursement", label: "报销" },
|
||||||
{ value: "leave", label: "请假" },
|
{ value: "leave", label: "请假" },
|
||||||
{ value: "procurement", label: "采购" },
|
{ value: "procurement", label: "采购" },
|
||||||
|
{ value: "travel", label: "差旅" },
|
||||||
{ value: "asset", label: "资产" },
|
{ value: "asset", label: "资产" },
|
||||||
{ value: "inventory", label: "盘点" },
|
{ value: "inventory", label: "盘点" },
|
||||||
] as const;
|
] as const;
|
||||||
@@ -85,6 +86,8 @@ const NOTIFICATION_TYPE_OPTIONS = [
|
|||||||
{ value: "leave.rejected", label: "leave.rejected" },
|
{ value: "leave.rejected", label: "leave.rejected" },
|
||||||
{ value: "purchase_request.approved", label: "purchase_request.approved" },
|
{ value: "purchase_request.approved", label: "purchase_request.approved" },
|
||||||
{ value: "purchase_request.rejected", label: "purchase_request.rejected" },
|
{ value: "purchase_request.rejected", label: "purchase_request.rejected" },
|
||||||
|
{ value: "travel_request.approved", label: "travel_request.approved" },
|
||||||
|
{ value: "travel_request.rejected", label: "travel_request.rejected" },
|
||||||
{ value: "asset.assigned", label: "asset.assigned" },
|
{ value: "asset.assigned", label: "asset.assigned" },
|
||||||
{ value: "asset.returned", label: "asset.returned" },
|
{ value: "asset.returned", label: "asset.returned" },
|
||||||
{ value: "asset.transferred", label: "asset.transferred" },
|
{ value: "asset.transferred", label: "asset.transferred" },
|
||||||
|
|||||||
@@ -0,0 +1,994 @@
|
|||||||
|
import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
|
import { isApiError } from "../api/types";
|
||||||
|
import {
|
||||||
|
createTravelRequest,
|
||||||
|
fetchTravelRequestDetail,
|
||||||
|
fetchTravelRequests,
|
||||||
|
submitTravelRequest,
|
||||||
|
updateTravelRequest,
|
||||||
|
withdrawTravelRequest,
|
||||||
|
type TravelRequestDetail,
|
||||||
|
type TravelRequestPayload,
|
||||||
|
type TravelRequestStatus,
|
||||||
|
type TravelSegmentRole,
|
||||||
|
type TravelTransportType,
|
||||||
|
type TravelTripType,
|
||||||
|
} from "../api/travelRequests";
|
||||||
|
import { fetchCostCenters, fetchLegalEntities } from "../api/masterData";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import { hasPermission } from "../auth/permissions";
|
||||||
|
import { CardSkeleton } from "../components/common/CardSkeleton";
|
||||||
|
import { EmptyStateCard } from "../components/common/EmptyStateCard";
|
||||||
|
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 { Textarea } from "../components/ui/textarea";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 12;
|
||||||
|
|
||||||
|
type DraftSegment = {
|
||||||
|
localId: string;
|
||||||
|
segmentRole: TravelSegmentRole;
|
||||||
|
transportType: TravelTransportType;
|
||||||
|
fromCity: string;
|
||||||
|
toCity: string;
|
||||||
|
departAt: string;
|
||||||
|
arriveAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 emptySegment(): DraftSegment {
|
||||||
|
return {
|
||||||
|
localId: crypto.randomUUID(),
|
||||||
|
segmentRole: "outbound",
|
||||||
|
transportType: "flight",
|
||||||
|
fromCity: "",
|
||||||
|
toCity: "",
|
||||||
|
departAt: "",
|
||||||
|
arriveAt: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateTimeLocalValue(value: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const pad = (input: number) => String(input).padStart(2, "0");
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toApiDateTime(value: string) {
|
||||||
|
if (!value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tripTypeLabel(value: TravelTripType) {
|
||||||
|
return value === "international" ? "国际" : "国内";
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentRoleLabel(value: TravelSegmentRole) {
|
||||||
|
if (value === "outbound") return "去程";
|
||||||
|
if (value === "return") return "返程";
|
||||||
|
return "中转";
|
||||||
|
}
|
||||||
|
|
||||||
|
function transportTypeLabel(value: TravelTransportType) {
|
||||||
|
if (value === "flight") return "航班";
|
||||||
|
if (value === "train") return "火车";
|
||||||
|
if (value === "car") return "汽车";
|
||||||
|
if (value === "bus") return "大巴";
|
||||||
|
return "其他";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(values: {
|
||||||
|
purpose: string;
|
||||||
|
tripType: TravelTripType;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
legalEntityId: string;
|
||||||
|
costCenterId: string;
|
||||||
|
estimatedAmount: string;
|
||||||
|
segments: DraftSegment[];
|
||||||
|
}): TravelRequestPayload {
|
||||||
|
return {
|
||||||
|
purpose: values.purpose.trim(),
|
||||||
|
tripType: values.tripType,
|
||||||
|
startDate: values.startDate,
|
||||||
|
endDate: values.endDate,
|
||||||
|
legalEntityId: values.legalEntityId,
|
||||||
|
costCenterId: values.costCenterId,
|
||||||
|
estimatedAmount: values.estimatedAmount,
|
||||||
|
segments: values.segments.map((item) => ({
|
||||||
|
segmentRole: item.segmentRole,
|
||||||
|
transportType: item.transportType,
|
||||||
|
fromCity: item.fromCity.trim(),
|
||||||
|
toCity: item.toCity.trim(),
|
||||||
|
departAt: toApiDateTime(item.departAt),
|
||||||
|
arriveAt: toApiDateTime(item.arriveAt),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPatchPayload(values: Parameters<typeof buildPayload>[0]) {
|
||||||
|
return buildPayload(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDraftSegments(detail: TravelRequestDetail | null): DraftSegment[] {
|
||||||
|
if (!detail?.segments?.length) {
|
||||||
|
return [emptySegment()];
|
||||||
|
}
|
||||||
|
|
||||||
|
return detail.segments.map((item) => ({
|
||||||
|
localId: item.id,
|
||||||
|
segmentRole: item.segmentRole,
|
||||||
|
transportType: item.transportType,
|
||||||
|
fromCity: item.fromCity,
|
||||||
|
toCity: item.toCity,
|
||||||
|
departAt: toDateTimeLocalValue(item.departAt),
|
||||||
|
arriveAt: toDateTimeLocalValue(item.arriveAt),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TravelRequestsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { permissions } = useAuth();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const canCreate = hasPermission(permissions, "travel.create");
|
||||||
|
const canViewAll = hasPermission(permissions, "travel.view_all") || hasPermission(permissions, "travel.manage");
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<TravelRequestStatus | "">("");
|
||||||
|
const [mine, setMine] = useState(true);
|
||||||
|
const [tripType, setTripType] = useState<TravelTripType | "">("");
|
||||||
|
const [legalEntityFilter, setLegalEntityFilter] = useState("");
|
||||||
|
const [costCenterFilter, setCostCenterFilter] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
const [selectedId, setSelectedId] = useState(searchParams.get("id") ?? "");
|
||||||
|
const [formError, setFormError] = useState("");
|
||||||
|
const [purpose, setPurpose] = useState("");
|
||||||
|
const [formTripType, setFormTripType] = useState<TravelTripType>("domestic");
|
||||||
|
const [startDate, setStartDate] = useState("");
|
||||||
|
const [endDate, setEndDate] = useState("");
|
||||||
|
const [legalEntityId, setLegalEntityId] = useState("");
|
||||||
|
const [costCenterId, setCostCenterId] = useState("");
|
||||||
|
const [estimatedAmount, setEstimatedAmount] = useState("0.00");
|
||||||
|
const [segments, setSegments] = useState<DraftSegment[]>([emptySegment()]);
|
||||||
|
|
||||||
|
const selectedQueryId = searchParams.get("id") ?? "";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedId(selectedQueryId);
|
||||||
|
}, [selectedQueryId]);
|
||||||
|
|
||||||
|
const legalEntitiesQuery = useQuery({
|
||||||
|
queryKey: ["travel-requests", "legal-entities"],
|
||||||
|
queryFn: () => fetchLegalEntities({ enabled: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const costCentersQuery = useQuery({
|
||||||
|
queryKey: ["travel-requests", "cost-centers"],
|
||||||
|
queryFn: () => fetchCostCenters({ enabled: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const listQuery = useQuery({
|
||||||
|
queryKey: ["travel-requests", "list", status, mine, tripType, legalEntityFilter, costCenterFilter, page],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchTravelRequests({
|
||||||
|
status: status || undefined,
|
||||||
|
mine: canViewAll ? mine : true,
|
||||||
|
tripType: tripType || undefined,
|
||||||
|
legalEntityId: legalEntityFilter || undefined,
|
||||||
|
costCenterId: costCenterFilter || undefined,
|
||||||
|
page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const detailQuery = useQuery({
|
||||||
|
enabled: Boolean(selectedId),
|
||||||
|
queryKey: ["travel-requests", "detail", selectedId],
|
||||||
|
queryFn: () => fetchTravelRequestDetail(selectedId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectableCostCenters = useMemo(() => {
|
||||||
|
return (costCentersQuery.data?.items ?? []).filter((item) => {
|
||||||
|
if (!legalEntityId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return item.legalEntityId === legalEntityId;
|
||||||
|
});
|
||||||
|
}, [costCentersQuery.data?.items, legalEntityId]);
|
||||||
|
|
||||||
|
const filterableCostCenters = useMemo(() => {
|
||||||
|
return (costCentersQuery.data?.items ?? []).filter((item) => {
|
||||||
|
if (!legalEntityFilter) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return item.legalEntityId === legalEntityFilter;
|
||||||
|
});
|
||||||
|
}, [costCentersQuery.data?.items, legalEntityFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (detailQuery.data) {
|
||||||
|
setPurpose(detailQuery.data.purpose ?? "");
|
||||||
|
setFormTripType(detailQuery.data.tripType);
|
||||||
|
setStartDate(detailQuery.data.startDate ?? "");
|
||||||
|
setEndDate(detailQuery.data.endDate ?? "");
|
||||||
|
setLegalEntityId(detailQuery.data.legalEntityId ?? "");
|
||||||
|
setCostCenterId(detailQuery.data.costCenterId ?? "");
|
||||||
|
setEstimatedAmount(detailQuery.data.estimatedAmount ?? "0.00");
|
||||||
|
setSegments(buildDraftSegments(detailQuery.data));
|
||||||
|
setFormError("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedId) {
|
||||||
|
setPurpose("");
|
||||||
|
setFormTripType("domestic");
|
||||||
|
setStartDate("");
|
||||||
|
setEndDate("");
|
||||||
|
setLegalEntityId("");
|
||||||
|
setCostCenterId("");
|
||||||
|
setEstimatedAmount("0.00");
|
||||||
|
setSegments([emptySegment()]);
|
||||||
|
setFormError("");
|
||||||
|
}
|
||||||
|
}, [detailQuery.data, selectedId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedId || canCreate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextId = listQuery.data?.items?.[0]?.id ?? "";
|
||||||
|
if (!nextId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
next.set("id", nextId);
|
||||||
|
setSearchParams(next);
|
||||||
|
}, [canCreate, listQuery.data?.items, selectedId, setSearchParams]);
|
||||||
|
|
||||||
|
const detail = detailQuery.data ?? null;
|
||||||
|
const isDraftEditing = Boolean(detail && detail.status === "draft" && detail.permissions.canEditDraft);
|
||||||
|
|
||||||
|
function selectTravelRequest(nextId: string) {
|
||||||
|
setSelectedId(nextId);
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
if (nextId) {
|
||||||
|
next.set("id", nextId);
|
||||||
|
} else {
|
||||||
|
next.delete("id");
|
||||||
|
}
|
||||||
|
setSearchParams(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm() {
|
||||||
|
if (!canCreate && !selectedId) {
|
||||||
|
throw new Error("当前无可保存的差旅申请");
|
||||||
|
}
|
||||||
|
if (!purpose.trim()) {
|
||||||
|
throw new Error("请填写出差目的");
|
||||||
|
}
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
throw new Error("请选择出差起止日期");
|
||||||
|
}
|
||||||
|
if (!legalEntityId || !costCenterId) {
|
||||||
|
throw new Error("请选择法体与成本中心");
|
||||||
|
}
|
||||||
|
if (!estimatedAmount || Number(estimatedAmount) < 0) {
|
||||||
|
throw new Error("请填写有效的预估金额");
|
||||||
|
}
|
||||||
|
if (!segments.length) {
|
||||||
|
throw new Error("至少需要一条行程段");
|
||||||
|
}
|
||||||
|
for (const item of segments) {
|
||||||
|
if (!item.fromCity.trim() || !item.toCity.trim()) {
|
||||||
|
throw new Error("请补全行程段的出发地与目的地");
|
||||||
|
}
|
||||||
|
if (!item.departAt || !item.arriveAt) {
|
||||||
|
throw new Error("请补全行程段的出发与到达时间");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
validateForm();
|
||||||
|
const values = { purpose, tripType: formTripType, startDate, endDate, legalEntityId, costCenterId, estimatedAmount, segments };
|
||||||
|
if (selectedId && isDraftEditing) {
|
||||||
|
return updateTravelRequest(selectedId, buildPatchPayload(values));
|
||||||
|
}
|
||||||
|
return createTravelRequest(buildPayload(values));
|
||||||
|
},
|
||||||
|
onSuccess: async (travelRequest) => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["travel-requests"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
|
||||||
|
]);
|
||||||
|
setFormError("");
|
||||||
|
selectTravelRequest(travelRequest.id);
|
||||||
|
toast.success(selectedId && isDraftEditing ? "差旅草稿已更新" : "差旅草稿已创建");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const message = resolveError(error);
|
||||||
|
setFormError(message);
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!selectedId) {
|
||||||
|
throw new Error("请选择待提交的差旅申请");
|
||||||
|
}
|
||||||
|
return submitTravelRequest(selectedId);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["travel-requests"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
|
||||||
|
]);
|
||||||
|
toast.success("差旅申请已提交");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const message = resolveError(error);
|
||||||
|
setFormError(message);
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const withdrawMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!selectedId) {
|
||||||
|
throw new Error("请选择待撤回的差旅申请");
|
||||||
|
}
|
||||||
|
return withdrawTravelRequest(selectedId);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["travel-requests"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
|
||||||
|
]);
|
||||||
|
toast.success("差旅申请已撤回");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const message = resolveError(error);
|
||||||
|
setFormError(message);
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="差旅申请"
|
||||||
|
subtitle="差旅主单、行程段、预算锁定与审批待办统一收口。"
|
||||||
|
actions={
|
||||||
|
canCreate ? (
|
||||||
|
<Button type="button" onClick={() => selectTravelRequest("")}>新建差旅申请</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{formError ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{formError}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||||
|
<section className="card-std space-y-4 p-5">
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="travel-filter-status">状态</Label>
|
||||||
|
<select
|
||||||
|
id="travel-filter-status"
|
||||||
|
className="select-std mt-1"
|
||||||
|
value={status}
|
||||||
|
onChange={(event) => {
|
||||||
|
setStatus(event.target.value as TravelRequestStatus | "");
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="draft">草稿</option>
|
||||||
|
<option value="reviewing">审批中</option>
|
||||||
|
<option value="approved">已通过</option>
|
||||||
|
<option value="rejected">已驳回</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canViewAll ? (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="travel-filter-scope">范围</Label>
|
||||||
|
<select
|
||||||
|
id="travel-filter-scope"
|
||||||
|
className="select-std mt-1"
|
||||||
|
value={mine ? "mine" : "all"}
|
||||||
|
onChange={(event) => {
|
||||||
|
setMine(event.target.value !== "all");
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="mine">仅看我的</option>
|
||||||
|
<option value="all">查看全部</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="travel-filter-trip-type">出差类型</Label>
|
||||||
|
<select
|
||||||
|
id="travel-filter-trip-type"
|
||||||
|
className="select-std mt-1"
|
||||||
|
value={tripType}
|
||||||
|
onChange={(event) => {
|
||||||
|
setTripType(event.target.value as TravelTripType | "");
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">全部类型</option>
|
||||||
|
<option value="domestic">国内</option>
|
||||||
|
<option value="international">国际</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="travel-filter-legal-entity">法体</Label>
|
||||||
|
<select
|
||||||
|
id="travel-filter-legal-entity"
|
||||||
|
className="select-std mt-1"
|
||||||
|
value={legalEntityFilter}
|
||||||
|
onChange={(event) => {
|
||||||
|
setLegalEntityFilter(event.target.value);
|
||||||
|
setCostCenterFilter("");
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">全部法体</option>
|
||||||
|
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>{item.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="travel-filter-cost-center">成本中心</Label>
|
||||||
|
<select
|
||||||
|
id="travel-filter-cost-center"
|
||||||
|
className="select-std mt-1"
|
||||||
|
value={costCenterFilter}
|
||||||
|
onChange={(event) => {
|
||||||
|
setCostCenterFilter(event.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">全部成本中心</option>
|
||||||
|
{filterableCostCenters.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>{item.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{listQuery.isLoading ? <TableSkeleton columns={1} rows={6} /> : null}
|
||||||
|
{listQuery.isError ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(listQuery.error)}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!listQuery.isLoading && !listQuery.isError ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(listQuery.data?.items ?? []).length ? (
|
||||||
|
(listQuery.data?.items ?? []).map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectTravelRequest(item.id)}
|
||||||
|
className={`w-full rounded-xl border px-4 py-3 text-left transition ${
|
||||||
|
item.id === selectedId ? "border-primary bg-primary-50" : "border-zinc-200 hover:border-zinc-300"
|
||||||
|
}`}
|
||||||
|
data-testid={`travel-request-item-${item.id}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-zinc-900">{item.requestNo ?? item.purpose}</p>
|
||||||
|
<p className="mt-1 text-xs text-zinc-500 line-clamp-2">{item.purpose}</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={item.status} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 space-y-1 text-xs text-zinc-500">
|
||||||
|
<div>{tripTypeLabel(item.tripType)} · {item.startDate ?? "-"} → {item.endDate ?? "-"}</div>
|
||||||
|
<div>预估金额:{item.estimatedAmount}</div>
|
||||||
|
<div>申请人:{item.applicant?.name ?? "-"}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyStateCard
|
||||||
|
title="暂无差旅申请"
|
||||||
|
description={canCreate ? "点击右上角创建新的差旅草稿。" : "当前筛选条件下没有可查看的差旅申请。"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} testIdPrefix="travel-requests" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-6">
|
||||||
|
{selectedId && detailQuery.isLoading ? <CardSkeleton lines={10} /> : null}
|
||||||
|
|
||||||
|
{selectedId && detailQuery.isError ? (
|
||||||
|
<div className="card-std p-6 text-sm text-red-600">{resolveError(detailQuery.error)}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!selectedId ? (
|
||||||
|
canCreate ? (
|
||||||
|
<div className="card-std space-y-6 p-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-zinc-900">新建差旅申请</h3>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">填写主单与行程段,保存为草稿后可继续编辑并提交审批。</p>
|
||||||
|
</div>
|
||||||
|
<TravelRequestForm
|
||||||
|
purpose={purpose}
|
||||||
|
setPurpose={setPurpose}
|
||||||
|
tripType={formTripType}
|
||||||
|
setTripType={setFormTripType}
|
||||||
|
startDate={startDate}
|
||||||
|
setStartDate={setStartDate}
|
||||||
|
endDate={endDate}
|
||||||
|
setEndDate={setEndDate}
|
||||||
|
legalEntityId={legalEntityId}
|
||||||
|
setLegalEntityId={setLegalEntityId}
|
||||||
|
legalEntities={legalEntitiesQuery.data?.items ?? []}
|
||||||
|
costCenterId={costCenterId}
|
||||||
|
setCostCenterId={setCostCenterId}
|
||||||
|
costCenters={selectableCostCenters}
|
||||||
|
estimatedAmount={estimatedAmount}
|
||||||
|
setEstimatedAmount={setEstimatedAmount}
|
||||||
|
segments={segments}
|
||||||
|
setSegments={setSegments}
|
||||||
|
readOnly={false}
|
||||||
|
budgetPoolName="待提交时自动解析"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="button" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending} data-testid="travel-request-save">
|
||||||
|
{saveMutation.isPending ? "保存中..." : "保存草稿"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyStateCard title="请选择差旅申请" description="从左侧选择一条差旅申请查看详情。" />
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{detail ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="card-std space-y-4 p-6">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h3 className="text-lg font-semibold text-zinc-900">{detail.requestNo ?? "差旅草稿"}</h3>
|
||||||
|
<StatusBadge status={detail.status} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">{detail.purpose}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{detail.permissions.canSubmit ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => submitMutation.mutate()}
|
||||||
|
disabled={submitMutation.isPending}
|
||||||
|
data-testid="travel-request-submit"
|
||||||
|
>
|
||||||
|
{submitMutation.isPending ? "提交中..." : "提交审批"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{detail.permissions.canWithdraw ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => withdrawMutation.mutate()}
|
||||||
|
disabled={withdrawMutation.isPending}
|
||||||
|
data-testid="travel-request-withdraw"
|
||||||
|
>
|
||||||
|
{withdrawMutation.isPending ? "撤回中..." : "撤回到草稿"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{isDraftEditing ? (
|
||||||
|
<Button type="button" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending} data-testid="travel-request-update">
|
||||||
|
{saveMutation.isPending ? "保存中..." : "保存修改"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-zinc-500">申请人</p>
|
||||||
|
<p className="font-medium text-zinc-900">{detail.applicant?.name ?? "-"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-zinc-500">出差类型</p>
|
||||||
|
<p className="font-medium text-zinc-900">{tripTypeLabel(detail.tripType)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-zinc-500">起止日期</p>
|
||||||
|
<p className="font-medium text-zinc-900">{detail.startDate ?? "-"} → {detail.endDate ?? "-"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-zinc-500">预算池</p>
|
||||||
|
<p className="font-medium text-zinc-900">{detail.budgetPool?.name ?? "待提交时自动解析"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-zinc-500">法体</p>
|
||||||
|
<p className="font-medium text-zinc-900">{detail.legalEntity?.name ?? "-"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-zinc-500">成本中心</p>
|
||||||
|
<p className="font-medium text-zinc-900">{detail.costCenter?.name ?? "-"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-zinc-500">预估金额</p>
|
||||||
|
<p className="font-medium text-zinc-900">{detail.estimatedAmount}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-zinc-500">提交人</p>
|
||||||
|
<p className="font-medium text-zinc-900">{detail.submitter?.name ?? "-"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-std p-6">
|
||||||
|
<TravelRequestForm
|
||||||
|
purpose={purpose}
|
||||||
|
setPurpose={setPurpose}
|
||||||
|
tripType={formTripType}
|
||||||
|
setTripType={setFormTripType}
|
||||||
|
startDate={startDate}
|
||||||
|
setStartDate={setStartDate}
|
||||||
|
endDate={endDate}
|
||||||
|
setEndDate={setEndDate}
|
||||||
|
legalEntityId={legalEntityId}
|
||||||
|
setLegalEntityId={setLegalEntityId}
|
||||||
|
legalEntities={legalEntitiesQuery.data?.items ?? []}
|
||||||
|
costCenterId={costCenterId}
|
||||||
|
setCostCenterId={setCostCenterId}
|
||||||
|
costCenters={selectableCostCenters}
|
||||||
|
estimatedAmount={estimatedAmount}
|
||||||
|
setEstimatedAmount={setEstimatedAmount}
|
||||||
|
segments={segments}
|
||||||
|
setSegments={setSegments}
|
||||||
|
readOnly={!isDraftEditing}
|
||||||
|
budgetPoolName={detail.budgetPool?.name ?? "待提交时自动解析"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
|
<div className="card-std space-y-3 p-6">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">审批轨道</h3>
|
||||||
|
{detail.pendingWorkflow ? (
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div>实例:{detail.pendingWorkflow.instanceId}</div>
|
||||||
|
<div>状态:<StatusBadge status={detail.pendingWorkflow.currentStatus} /></div>
|
||||||
|
<div>当前步骤:{detail.pendingWorkflow.task?.stepName ?? detail.pendingWorkflow.currentStepId}</div>
|
||||||
|
<div>审批角色:{detail.pendingWorkflow.task?.assigneeRole ?? "-"}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-zinc-500">当前没有进行中的审批实例。</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-std space-y-3 p-6">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">关联差旅订单</h3>
|
||||||
|
{detail.travelOrders.length ? (
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
{detail.travelOrders.map((item) => (
|
||||||
|
<div key={item.id} className="rounded-lg border border-zinc-200 p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="font-medium text-zinc-900">{item.externalOrderNo}</div>
|
||||||
|
<StatusBadge status={item.status} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-zinc-500 space-y-1">
|
||||||
|
<div>来源:{item.source}</div>
|
||||||
|
<div>发生日期:{item.occurredAt ?? "-"}</div>
|
||||||
|
<div>金额:{item.amount ?? "-"} {item.currency}</div>
|
||||||
|
<div>报销关联:{item.linkedReimbursementId ?? "-"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-zinc-500">当前暂无关联的 `travel_orders` 入站记录。</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type TravelRequestFormProps = {
|
||||||
|
purpose: string;
|
||||||
|
setPurpose: (value: string) => void;
|
||||||
|
tripType: TravelTripType;
|
||||||
|
setTripType: (value: TravelTripType) => void;
|
||||||
|
startDate: string;
|
||||||
|
setStartDate: (value: string) => void;
|
||||||
|
endDate: string;
|
||||||
|
setEndDate: (value: string) => void;
|
||||||
|
legalEntityId: string;
|
||||||
|
setLegalEntityId: (value: string) => void;
|
||||||
|
legalEntities: Array<{ id: string; name: string; code: string }>;
|
||||||
|
costCenterId: string;
|
||||||
|
setCostCenterId: (value: string) => void;
|
||||||
|
costCenters: Array<{ id: string; name: string; code: string }>;
|
||||||
|
estimatedAmount: string;
|
||||||
|
setEstimatedAmount: (value: string) => void;
|
||||||
|
segments: DraftSegment[];
|
||||||
|
setSegments: Dispatch<SetStateAction<DraftSegment[]>>;
|
||||||
|
readOnly: boolean;
|
||||||
|
budgetPoolName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function TravelRequestForm(props: TravelRequestFormProps) {
|
||||||
|
const {
|
||||||
|
purpose,
|
||||||
|
setPurpose,
|
||||||
|
tripType,
|
||||||
|
setTripType,
|
||||||
|
startDate,
|
||||||
|
setStartDate,
|
||||||
|
endDate,
|
||||||
|
setEndDate,
|
||||||
|
legalEntityId,
|
||||||
|
setLegalEntityId,
|
||||||
|
legalEntities,
|
||||||
|
costCenterId,
|
||||||
|
setCostCenterId,
|
||||||
|
costCenters,
|
||||||
|
estimatedAmount,
|
||||||
|
setEstimatedAmount,
|
||||||
|
segments,
|
||||||
|
setSegments,
|
||||||
|
readOnly,
|
||||||
|
budgetPoolName,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Label htmlFor="travel-purpose">出差目的</Label>
|
||||||
|
<Textarea
|
||||||
|
id="travel-purpose"
|
||||||
|
value={purpose}
|
||||||
|
onChange={(event) => setPurpose(event.target.value)}
|
||||||
|
rows={3}
|
||||||
|
readOnly={readOnly}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="travel-trip-type">出差类型</Label>
|
||||||
|
<select
|
||||||
|
id="travel-trip-type"
|
||||||
|
className="select-std mt-1"
|
||||||
|
value={tripType}
|
||||||
|
onChange={(event) => setTripType(event.target.value as TravelTripType)}
|
||||||
|
disabled={readOnly}
|
||||||
|
>
|
||||||
|
<option value="domestic">国内</option>
|
||||||
|
<option value="international">国际</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="travel-amount">预估金额</Label>
|
||||||
|
<Input id="travel-amount" className="mt-1" value={estimatedAmount} onChange={(event) => setEstimatedAmount(event.target.value)} readOnly={readOnly} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="travel-start-date">开始日期</Label>
|
||||||
|
<Input id="travel-start-date" className="mt-1" type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} readOnly={readOnly} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="travel-end-date">结束日期</Label>
|
||||||
|
<Input id="travel-end-date" className="mt-1" type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} readOnly={readOnly} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="travel-legal-entity">法体</Label>
|
||||||
|
<select
|
||||||
|
id="travel-legal-entity"
|
||||||
|
className="select-std mt-1"
|
||||||
|
value={legalEntityId}
|
||||||
|
onChange={(event) => {
|
||||||
|
setLegalEntityId(event.target.value);
|
||||||
|
setCostCenterId("");
|
||||||
|
}}
|
||||||
|
disabled={readOnly}
|
||||||
|
>
|
||||||
|
<option value="">请选择法体</option>
|
||||||
|
{legalEntities.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>{item.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="travel-cost-center">成本中心</Label>
|
||||||
|
<select
|
||||||
|
id="travel-cost-center"
|
||||||
|
className="select-std mt-1"
|
||||||
|
value={costCenterId}
|
||||||
|
onChange={(event) => setCostCenterId(event.target.value)}
|
||||||
|
disabled={readOnly}
|
||||||
|
>
|
||||||
|
<option value="">请选择成本中心</option>
|
||||||
|
{costCenters.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>{item.code} · {item.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Label>预算池</Label>
|
||||||
|
<div className="mt-1 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">{budgetPoolName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">行程段</h3>
|
||||||
|
{!readOnly ? (
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setSegments((current) => [...current, emptySegment()])}>
|
||||||
|
新增行程段
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{segments.map((segment, index) => (
|
||||||
|
<div key={segment.localId} className="rounded-xl border border-zinc-200 p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm font-medium text-zinc-900">第 {index + 1} 段</p>
|
||||||
|
{!readOnly && segments.length > 1 ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setSegments((current) => current.filter((item) => item.localId !== segment.localId))}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<Label>角色</Label>
|
||||||
|
<select
|
||||||
|
className="select-std mt-1"
|
||||||
|
value={segment.segmentRole}
|
||||||
|
disabled={readOnly}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSegments((current) =>
|
||||||
|
current.map((item) => item.localId === segment.localId ? { ...item, segmentRole: event.target.value as TravelSegmentRole } : item),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="outbound">去程</option>
|
||||||
|
<option value="return">返程</option>
|
||||||
|
<option value="connection">中转</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>交通方式</Label>
|
||||||
|
<select
|
||||||
|
className="select-std mt-1"
|
||||||
|
value={segment.transportType}
|
||||||
|
disabled={readOnly}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSegments((current) =>
|
||||||
|
current.map((item) => item.localId === segment.localId ? { ...item, transportType: event.target.value as TravelTransportType } : item),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="flight">航班</option>
|
||||||
|
<option value="train">火车</option>
|
||||||
|
<option value="car">汽车</option>
|
||||||
|
<option value="bus">大巴</option>
|
||||||
|
<option value="other">其他</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>出发地</Label>
|
||||||
|
<Input
|
||||||
|
className="mt-1"
|
||||||
|
value={segment.fromCity}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSegments((current) =>
|
||||||
|
current.map((item) => item.localId === segment.localId ? { ...item, fromCity: event.target.value } : item),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>目的地</Label>
|
||||||
|
<Input
|
||||||
|
className="mt-1"
|
||||||
|
value={segment.toCity}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSegments((current) =>
|
||||||
|
current.map((item) => item.localId === segment.localId ? { ...item, toCity: event.target.value } : item),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>出发时间</Label>
|
||||||
|
<Input
|
||||||
|
className="mt-1"
|
||||||
|
type="datetime-local"
|
||||||
|
value={segment.departAt}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSegments((current) =>
|
||||||
|
current.map((item) => item.localId === segment.localId ? { ...item, departAt: event.target.value } : item),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>到达时间</Label>
|
||||||
|
<Input
|
||||||
|
className="mt-1"
|
||||||
|
type="datetime-local"
|
||||||
|
value={segment.arriveAt}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSegments((current) =>
|
||||||
|
current.map((item) => item.localId === segment.localId ? { ...item, arriveAt: event.target.value } : item),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{readOnly ? (
|
||||||
|
<p className="mt-3 text-xs text-zinc-500">{segmentRoleLabel(segment.segmentRole)} · {transportTypeLabel(segment.transportType)} · {segment.fromCity} → {segment.toCity}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,6 +30,9 @@ function entityTitle(entityType: string | undefined) {
|
|||||||
if (entityType === "purchase_request") {
|
if (entityType === "purchase_request") {
|
||||||
return "采购申请";
|
return "采购申请";
|
||||||
}
|
}
|
||||||
|
if (entityType === "travel_request") {
|
||||||
|
return "差旅申请";
|
||||||
|
}
|
||||||
return entityType || "业务单据";
|
return entityType || "业务单据";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,6 +203,26 @@ export function WorkflowTaskDetailPage() {
|
|||||||
<div>说明:{detail.purchaseRequest.reason ?? "-"}</div>
|
<div>说明:{detail.purchaseRequest.reason ?? "-"}</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
{detail.travelRequest ? (
|
||||||
|
<>
|
||||||
|
<div>差旅单号:{detail.travelRequest.requestNo ?? detail.travelRequest.id}</div>
|
||||||
|
<div>申请人:{detail.travelRequest.applicant?.name ?? "-"}</div>
|
||||||
|
<div>出差类型:{detail.travelRequest.tripType === "international" ? "国际" : "国内"}</div>
|
||||||
|
<div>法体:{detail.travelRequest.legalEntity?.name ?? "-"}</div>
|
||||||
|
<div>成本中心:{detail.travelRequest.costCenter?.name ?? "-"}</div>
|
||||||
|
<div>起止日期:{detail.travelRequest.startDate ?? "-"} → {detail.travelRequest.endDate ?? "-"}</div>
|
||||||
|
<div>预估金额:{detail.travelRequest.estimatedAmount ?? "-"}</div>
|
||||||
|
<div>出差目的:{detail.travelRequest.purpose || "-"}</div>
|
||||||
|
<div>
|
||||||
|
行程段:
|
||||||
|
{detail.travelRequest.segments.length
|
||||||
|
? detail.travelRequest.segments
|
||||||
|
.map((item) => `${item.fromCity}→${item.toCity}(${item.segmentRole})`)
|
||||||
|
.join(";")
|
||||||
|
: "-"}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card-std space-y-4 p-6">
|
<div className="card-std space-y-4 p-6">
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ function entityLabel(entityType: string | undefined) {
|
|||||||
if (entityType === "purchase_request") {
|
if (entityType === "purchase_request") {
|
||||||
return "采购";
|
return "采购";
|
||||||
}
|
}
|
||||||
|
if (entityType === "travel_request") {
|
||||||
|
return "差旅";
|
||||||
|
}
|
||||||
return entityType || "-";
|
return entityType || "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +51,7 @@ export function WorkflowTasksPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader title="待我处理" subtitle="审批待办列表,覆盖报销、请假与采购流程。" />
|
<PageHeader title="待我处理" subtitle="审批待办列表,覆盖报销、请假、采购与差旅流程。" />
|
||||||
|
|
||||||
<div className="card-std p-6 space-y-4">
|
<div className="card-std p-6 space-y-4">
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
+13
-3
@@ -20,6 +20,7 @@ import { NotificationsPage } from "../pages/NotificationsPage";
|
|||||||
import { NotificationChannelsPage } from "../pages/NotificationChannelsPage";
|
import { NotificationChannelsPage } from "../pages/NotificationChannelsPage";
|
||||||
import { ErpIntegrationPage } from "../pages/ErpIntegrationPage";
|
import { ErpIntegrationPage } from "../pages/ErpIntegrationPage";
|
||||||
import { PurchaseRequestsPage } from "../pages/PurchaseRequestsPage";
|
import { PurchaseRequestsPage } from "../pages/PurchaseRequestsPage";
|
||||||
|
import { TravelRequestsPage } from "../pages/TravelRequestsPage";
|
||||||
import { AssetRegistryPage } from "../pages/AssetRegistryPage";
|
import { AssetRegistryPage } from "../pages/AssetRegistryPage";
|
||||||
import { InventoryChecksPage } from "../pages/InventoryChecksPage";
|
import { InventoryChecksPage } from "../pages/InventoryChecksPage";
|
||||||
import { NotFoundPage } from "../pages/NotFoundPage";
|
import { NotFoundPage } from "../pages/NotFoundPage";
|
||||||
@@ -112,6 +113,15 @@ export function AppRouter() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="travel-requests"
|
||||||
|
element={
|
||||||
|
<RequirePermission anyOf={["travel.create", "travel.view_own", "travel.view_all", "travel.manage"]}>
|
||||||
|
<TravelRequestsPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="assets"
|
path="assets"
|
||||||
element={
|
element={
|
||||||
@@ -133,7 +143,7 @@ export function AppRouter() {
|
|||||||
<Route
|
<Route
|
||||||
path="workflow/tasks"
|
path="workflow/tasks"
|
||||||
element={
|
element={
|
||||||
<RequirePermission anyOf={["expense.approve", "leave.approve", "procurement.approve"]}>
|
<RequirePermission anyOf={["expense.approve", "leave.approve", "procurement.approve", "travel.approve"]}>
|
||||||
<WorkflowTasksPage />
|
<WorkflowTasksPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -142,7 +152,7 @@ export function AppRouter() {
|
|||||||
<Route
|
<Route
|
||||||
path="workflow/tasks/:taskId"
|
path="workflow/tasks/:taskId"
|
||||||
element={
|
element={
|
||||||
<RequirePermission anyOf={["expense.approve", "leave.approve", "procurement.approve"]}>
|
<RequirePermission anyOf={["expense.approve", "leave.approve", "procurement.approve", "travel.approve"]}>
|
||||||
<WorkflowTaskDetailPage />
|
<WorkflowTaskDetailPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -169,7 +179,7 @@ export function AppRouter() {
|
|||||||
<Route
|
<Route
|
||||||
path="mobile/approvals"
|
path="mobile/approvals"
|
||||||
element={
|
element={
|
||||||
<RequirePermission anyOf={["expense.approve", "leave.approve", "procurement.approve"]}>
|
<RequirePermission anyOf={["expense.approve", "leave.approve", "procurement.approve", "travel.approve"]}>
|
||||||
<MobileApprovalsPage />
|
<MobileApprovalsPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户