feat(P20T2-4): 差旅支付轨道卡片与发票绑定前端交互
这个提交包含在:
@@ -21,6 +21,7 @@ import {
|
||||
type InvoicePoolStatus,
|
||||
} from "../api/invoicePool";
|
||||
import { fetchReimbursements } from "../api/reimbursements";
|
||||
import { fetchTravelRequestDetail, fetchTravelRequests } from "../api/travelRequests";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { toast } from "sonner";
|
||||
@@ -30,7 +31,7 @@ import { Button } from "../components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
|
||||
import { InvoiceUploadCard } from "./invoice-pool/InvoiceUploadCard";
|
||||
import { InvoiceListCard } from "./invoice-pool/InvoiceListCard";
|
||||
import { InvoiceBindCard } from "./invoice-pool/InvoiceBindCard";
|
||||
import { InvoiceBindCard, type InvoiceBindMode } from "./invoice-pool/InvoiceBindCard";
|
||||
import { InvoiceDetailPanel } from "./invoice-pool/InvoiceDetailPanel";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
@@ -43,6 +44,7 @@ export function InvoicePoolPage() {
|
||||
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
|
||||
const canVerifyInvoice = hasPermission(permissions, "invoice.verify");
|
||||
const canProxySubmit = hasPermission(permissions, "expense.proxy_submit");
|
||||
const canManageTravelPayments = hasPermission(permissions, "travel.payment.manage");
|
||||
const isAdministration = (me?.roleCodes ?? []).includes("administration");
|
||||
const showProxyOwnerSelector = canProxySubmit && isAdministration;
|
||||
const [status, setStatus] = useState<InvoicePoolStatus | "">("");
|
||||
@@ -51,7 +53,10 @@ export function InvoicePoolPage() {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [uploadOwnerEmployeeId, setUploadOwnerEmployeeId] = useState("");
|
||||
const [selectedInvoiceId, setSelectedInvoiceId] = useState("");
|
||||
const [bindMode, setBindMode] = useState<InvoiceBindMode>("reimbursement");
|
||||
const [selectedReimbursementId, setSelectedReimbursementId] = useState("");
|
||||
const [selectedTravelRequestId, setSelectedTravelRequestId] = useState("");
|
||||
const [selectedTravelOrderId, setSelectedTravelOrderId] = useState("");
|
||||
const [detailInvoiceId, setDetailInvoiceId] = useState("");
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editSetAvailable, setEditSetAvailable] = useState(false);
|
||||
@@ -81,6 +86,18 @@ export function InvoicePoolPage() {
|
||||
queryFn: () => fetchReimbursements({ page: 1, pageSize: 50, scope: "own" }),
|
||||
});
|
||||
|
||||
const travelRequestQuery = useQuery({
|
||||
enabled: canManageTravelPayments,
|
||||
queryKey: ["travel-requests", "for-invoice-bind"],
|
||||
queryFn: () => fetchTravelRequests({ status: "approved", mine: false, page: 1, pageSize: 50 }),
|
||||
});
|
||||
|
||||
const travelRequestDetailQuery = useQuery({
|
||||
enabled: canManageTravelPayments && bindMode === "travelOrder" && Boolean(selectedTravelRequestId),
|
||||
queryKey: ["travel-requests", "for-invoice-bind", selectedTravelRequestId],
|
||||
queryFn: () => fetchTravelRequestDetail(selectedTravelRequestId),
|
||||
});
|
||||
|
||||
const proxyDelegationsQuery = useQuery({
|
||||
enabled: showProxyOwnerSelector,
|
||||
queryKey: ["delegations", "mine", "active"],
|
||||
@@ -132,6 +149,15 @@ export function InvoicePoolPage() {
|
||||
}
|
||||
}, [canManageInvoice, ownerScope]);
|
||||
|
||||
useEffect(() => {
|
||||
if (canManageTravelPayments) {
|
||||
return;
|
||||
}
|
||||
setBindMode("reimbursement");
|
||||
setSelectedTravelRequestId("");
|
||||
setSelectedTravelOrderId("");
|
||||
}, [canManageTravelPayments]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!detailInvoiceId && listQuery.data?.items?.length) {
|
||||
setDetailInvoiceId(listQuery.data.items[0].id);
|
||||
@@ -169,14 +195,26 @@ export function InvoicePoolPage() {
|
||||
|
||||
const bindMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedInvoiceId || !selectedReimbursementId) {
|
||||
throw new Error("请选择票据与报销单");
|
||||
if (!selectedInvoiceId) {
|
||||
throw new Error("请选择票据");
|
||||
}
|
||||
return bindInvoice(selectedInvoiceId, selectedReimbursementId);
|
||||
if (bindMode === "reimbursement") {
|
||||
if (!selectedReimbursementId) {
|
||||
throw new Error("请选择报销单");
|
||||
}
|
||||
return bindInvoice(selectedInvoiceId, selectedReimbursementId);
|
||||
}
|
||||
if (!selectedTravelOrderId) {
|
||||
throw new Error("请选择差旅订单");
|
||||
}
|
||||
return bindInvoice(selectedInvoiceId, { travelOrderId: selectedTravelOrderId });
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||||
setError("");
|
||||
setSelectedInvoiceId("");
|
||||
setSelectedReimbursementId("");
|
||||
setSelectedTravelOrderId("");
|
||||
toast.success("绑定成功");
|
||||
},
|
||||
onError: (err) => handleError(err, "绑定失败,请稍后重试"),
|
||||
@@ -380,11 +418,20 @@ export function InvoicePoolPage() {
|
||||
<InvoiceBindCard
|
||||
listQuery={listQuery}
|
||||
reimbursementQuery={reimbursementQuery}
|
||||
travelRequestQuery={travelRequestQuery}
|
||||
travelRequestDetailQuery={travelRequestDetailQuery}
|
||||
bindMutation={bindMutation}
|
||||
bindMode={bindMode}
|
||||
setBindMode={setBindMode}
|
||||
canManageTravelPayments={canManageTravelPayments}
|
||||
selectedInvoiceId={selectedInvoiceId}
|
||||
setSelectedInvoiceId={setSelectedInvoiceId}
|
||||
selectedReimbursementId={selectedReimbursementId}
|
||||
setSelectedReimbursementId={setSelectedReimbursementId}
|
||||
selectedTravelRequestId={selectedTravelRequestId}
|
||||
setSelectedTravelRequestId={setSelectedTravelRequestId}
|
||||
selectedTravelOrderId={selectedTravelOrderId}
|
||||
setSelectedTravelOrderId={setSelectedTravelOrderId}
|
||||
/>
|
||||
|
||||
<InvoiceDetailPanel
|
||||
|
||||
@@ -28,6 +28,7 @@ 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 { TravelPaymentTrackCard } from "../components/travel/TravelPaymentTrackCard";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
@@ -723,6 +724,7 @@ export function TravelRequestsPage() {
|
||||
<div>发生日期:{item.occurredAt ?? "-"}</div>
|
||||
<div>金额:{item.amount ?? "-"} {item.currency}</div>
|
||||
<div>报销关联:{item.linkedReimbursementId ?? "-"}</div>
|
||||
<div>付款渠道:{item.paymentChannel ?? "-"} · 付款日期:{item.paymentDate ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -732,6 +734,8 @@ export function TravelRequestsPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TravelPaymentTrackCard travelRequestId={detail.id} />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
@@ -1,30 +1,68 @@
|
||||
import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query";
|
||||
import type { InvoicePoolListResponse } from "../../api/invoicePool";
|
||||
import type { ReimbursementListResponse } from "../../api/reimbursements";
|
||||
import type { TravelRequestDetail, TravelRequestListResponse } from "../../api/travelRequests";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
export type InvoiceBindMode = "reimbursement" | "travelOrder";
|
||||
|
||||
export type InvoiceBindCardProps = {
|
||||
listQuery: UseQueryResult<InvoicePoolListResponse, Error>;
|
||||
reimbursementQuery: UseQueryResult<ReimbursementListResponse, Error>;
|
||||
travelRequestQuery: UseQueryResult<TravelRequestListResponse, Error>;
|
||||
travelRequestDetailQuery: UseQueryResult<TravelRequestDetail, Error>;
|
||||
bindMutation: UseMutationResult<Record<string, unknown>, Error, void>;
|
||||
bindMode: InvoiceBindMode;
|
||||
setBindMode: (mode: InvoiceBindMode) => void;
|
||||
canManageTravelPayments: boolean;
|
||||
selectedInvoiceId: string;
|
||||
setSelectedInvoiceId: (id: string) => void;
|
||||
selectedReimbursementId: string;
|
||||
setSelectedReimbursementId: (id: string) => void;
|
||||
selectedTravelRequestId: string;
|
||||
setSelectedTravelRequestId: (id: string) => void;
|
||||
selectedTravelOrderId: string;
|
||||
setSelectedTravelOrderId: (id: string) => void;
|
||||
};
|
||||
|
||||
export function InvoiceBindCard({
|
||||
listQuery,
|
||||
reimbursementQuery,
|
||||
travelRequestQuery,
|
||||
travelRequestDetailQuery,
|
||||
bindMutation,
|
||||
bindMode,
|
||||
setBindMode,
|
||||
canManageTravelPayments,
|
||||
selectedInvoiceId,
|
||||
setSelectedInvoiceId,
|
||||
selectedReimbursementId,
|
||||
setSelectedReimbursementId,
|
||||
selectedTravelRequestId,
|
||||
setSelectedTravelRequestId,
|
||||
selectedTravelOrderId,
|
||||
setSelectedTravelOrderId,
|
||||
}: InvoiceBindCardProps) {
|
||||
const bindableTravelOrders = (travelRequestDetailQuery.data?.travelOrders ?? []).filter((item) =>
|
||||
["confirmed", "paid"].includes(item.status),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<h3 className="card-title">绑定报销</h3>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="card-title">绑定业务单据</h3>
|
||||
{canManageTravelPayments ? (
|
||||
<select
|
||||
className="select-std max-w-[180px]"
|
||||
value={bindMode}
|
||||
onChange={(event) => setBindMode(event.target.value as InvoiceBindMode)}
|
||||
>
|
||||
<option value="reimbursement">绑定报销</option>
|
||||
<option value="travelOrder">绑定差旅订单</option>
|
||||
</select>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="select-std"
|
||||
data-testid="invoice-bind-invoice"
|
||||
@@ -39,25 +77,65 @@ export function InvoiceBindCard({
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="select-std"
|
||||
data-testid="invoice-bind-reimbursement"
|
||||
onChange={(event) => setSelectedReimbursementId(event.target.value)}
|
||||
value={selectedReimbursementId}
|
||||
>
|
||||
<option value="">选择报销单</option>
|
||||
{(reimbursementQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.formNumber ?? item.id} ({item.status})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{bindMode === "reimbursement" ? (
|
||||
<select
|
||||
className="select-std"
|
||||
data-testid="invoice-bind-reimbursement"
|
||||
onChange={(event) => setSelectedReimbursementId(event.target.value)}
|
||||
value={selectedReimbursementId}
|
||||
>
|
||||
<option value="">选择报销单</option>
|
||||
{(reimbursementQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.formNumber ?? item.id} ({item.status})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
className="select-std"
|
||||
data-testid="invoice-bind-travel-request"
|
||||
onChange={(event) => {
|
||||
setSelectedTravelRequestId(event.target.value);
|
||||
setSelectedTravelOrderId("");
|
||||
}}
|
||||
value={selectedTravelRequestId}
|
||||
>
|
||||
<option value="">选择差旅申请</option>
|
||||
{(travelRequestQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.requestNo ?? item.purpose} ({item.status})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="select-std"
|
||||
data-testid="invoice-bind-travel-order"
|
||||
onChange={(event) => setSelectedTravelOrderId(event.target.value)}
|
||||
value={selectedTravelOrderId}
|
||||
disabled={!selectedTravelRequestId}
|
||||
>
|
||||
<option value="">选择差旅订单</option>
|
||||
{bindableTravelOrders.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.externalOrderNo} ({item.status})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
data-testid="invoice-bind-submit"
|
||||
className="btn-primary"
|
||||
disabled={bindMutation.isPending || !selectedInvoiceId || !selectedReimbursementId}
|
||||
disabled={
|
||||
bindMutation.isPending ||
|
||||
!selectedInvoiceId ||
|
||||
(bindMode === "reimbursement" ? !selectedReimbursementId : !selectedTravelOrderId)
|
||||
}
|
||||
onClick={() => bindMutation.mutate()}
|
||||
type="button"
|
||||
>
|
||||
|
||||
在新工单中引用
屏蔽一个用户