R57 A1-A6:报销模板与差旅申请关联

这个提交包含在:
2026-04-08 21:16:30 +08:00
父节点 14c2cbd6da
当前提交 88c987ae8f
共修改 17 个文件,包含 2886 行新增和 76 行删除
+121 -17
查看文件
@@ -1,11 +1,18 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchMyDelegations } from "../api/delegations";
import { exportReimbursements } from "../api/exports";
import { createReimbursement, fetchReimbursements, type ReimbursementStatus } from "../api/reimbursements";
import { fetchReimbursementTemplates } from "../api/reimbursementTemplates";
import {
createReimbursement,
fetchLinkableTravelRequests,
fetchReimbursements,
type ReimbursementCreatePayload,
type ReimbursementStatus,
} from "../api/reimbursements";
import {
batchApproveWorkflowTasks,
batchRejectWorkflowTasks,
@@ -30,7 +37,13 @@ import {
DialogHeader,
DialogTitle,
} from "../components/ui/dialog";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../components/ui/dropdown-menu";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import { Textarea } from "../components/ui/textarea";
@@ -78,6 +91,7 @@ function formatBatchOutcome(action: BatchAction, outcome: string) {
export function ReimbursementsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const { permissions, me } = useAuth();
const queryClient = useQueryClient();
const [exportError, setExportError] = useState("");
@@ -92,6 +106,7 @@ export function ReimbursementsPage() {
const status = (searchParams.get("status") as ReimbursementStatus | null) ?? "draft";
const page = Number(searchParams.get("page") ?? "1") || 1;
const scope = (searchParams.get("scope") as "own" | "all" | null) ?? "own";
const canCreateExpense = hasPermission(permissions, "expense.create");
const canViewAll = hasPermission(permissions, "expense.view_all");
const canApproveExpense = hasPermission(permissions, "expense.approve");
const canProxySubmit = hasPermission(permissions, "expense.proxy_submit");
@@ -109,6 +124,21 @@ export function ReimbursementsPage() {
queryFn: () => fetchMyDelegations("active"),
});
const templateOptionsQuery = useQuery({
enabled: canCreateExpense,
queryKey: ["reimbursement-templates", "enabled", "create-menu"],
queryFn: () => fetchReimbursementTemplates({ enabled: true }),
});
const linkableTravelRequestsQuery = useQuery({
enabled: canCreateExpense,
queryKey: ["reimbursements", "linkable-travel-requests", proxyOwnerEmployeeId || me?.id || "self"],
queryFn: () =>
fetchLinkableTravelRequests({
ownerEmployeeId: proxyOwnerEmployeeId || undefined,
}),
});
const items = listQuery.data?.items ?? [];
const stats = useMemo(() => {
return items.reduce(
@@ -123,6 +153,9 @@ export function ReimbursementsPage() {
);
}, [items]);
const templateOptions = templateOptionsQuery.data?.items ?? [];
const linkableTravelRequests = linkableTravelRequestsQuery.data?.items ?? [];
const actionableItems = useMemo(
() =>
items.filter(
@@ -151,13 +184,17 @@ export function ReimbursementsPage() {
}, [actionableIdSet, canShowBatchEntry]);
const createMutation = useMutation({
mutationFn: () =>
createReimbursement({
ownerEmployeeId: proxyOwnerEmployeeId || undefined,
}),
onSuccess: async () => {
mutationFn: (payload: ReimbursementCreatePayload) => createReimbursement(payload),
onSuccess: async (response, payload) => {
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
toast.success("报销单已创建");
if (payload.linkedTravelRequestId) {
toast.success("已按差旅申请创建报销单");
} else if (payload.templateId) {
toast.success("已按模板创建报销单");
} else {
toast.success("报销单已创建");
}
navigate(`/reimbursements/${response.id}`);
},
onError: (error) => {
toast.error(resolveErrorMessage(error, "创建报销单失败"));
@@ -293,6 +330,13 @@ export function ReimbursementsPage() {
});
}
function startCreateReimbursement(payload: Omit<ReimbursementCreatePayload, "ownerEmployeeId"> = {}) {
createMutation.mutate({
ownerEmployeeId: proxyOwnerEmployeeId || undefined,
...payload,
});
}
return (
<ListWorkspace>
<PageHeader
@@ -324,14 +368,62 @@ export function ReimbursementsPage() {
</Button>
) : null}
<Button
data-testid="reimbursements-create"
disabled={createMutation.isPending}
onClick={() => createMutation.mutate()}
type="button"
>
{createMutation.isPending ? "创建中..." : "新建报销"}
</Button>
{canCreateExpense ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="reimbursements-create"
disabled={createMutation.isPending}
type="button"
>
{createMutation.isPending ? "创建中..." : "新建报销"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<DropdownMenuItem onSelect={() => startCreateReimbursement()}>
空白报销
</DropdownMenuItem>
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
从模板创建
</div>
{templateOptionsQuery.isLoading ? (
<DropdownMenuItem disabled>正在加载模板...</DropdownMenuItem>
) : templateOptions.length ? (
templateOptions.map((template) => (
<DropdownMenuItem
key={template.id}
onSelect={() => startCreateReimbursement({ templateId: template.id })}
>
{template.name}
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>暂无可用模板</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
关联差旅申请
</div>
{linkableTravelRequestsQuery.isLoading ? (
<DropdownMenuItem disabled>正在加载差旅申请...</DropdownMenuItem>
) : linkableTravelRequests.length ? (
linkableTravelRequests.map((travelRequest) => (
<DropdownMenuItem
key={travelRequest.id}
onSelect={() => startCreateReimbursement({ linkedTravelRequestId: travelRequest.id })}
>
{travelRequest.requestNo ?? travelRequest.id} · {travelRequest.purpose}
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>暂无可关联的差旅申请</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -374,6 +466,18 @@ export function ReimbursementsPage() {
</p>
) : null}
{templateOptionsQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(templateOptionsQuery.error, "加载报销模板失败")}
</p>
) : null}
{linkableTravelRequestsQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(linkableTravelRequestsQuery.error, "加载可关联差旅申请失败")}
</p>
) : null}
{exportError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{exportError}