R57 A1-A6:报销模板与差旅申请关联
这个提交包含在:
@@ -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}
|
||||
|
||||
在新工单中引用
屏蔽一个用户