feat(P20T2-4): 差旅支付轨道卡片与发票绑定前端交互
这个提交包含在:
@@ -34,6 +34,7 @@ export type InvoicePoolListItem = {
|
||||
totalAmount: string | null;
|
||||
linkedReimbursementId: string | null;
|
||||
linkedItemId: string | null;
|
||||
linkedTravelOrderId: string | null;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
@@ -88,6 +89,7 @@ export type InvoicePoolDetail = {
|
||||
totalAmount: string | null;
|
||||
linkedReimbursementId: string | null;
|
||||
linkedItemId: string | null;
|
||||
linkedTravelOrderId: string | null;
|
||||
isDuplicateOverride: boolean;
|
||||
overrideRefFormNumber: string | null;
|
||||
overrideReason: string | null;
|
||||
@@ -104,6 +106,15 @@ export type InvoicePoolListQuery = {
|
||||
ownerScope?: "own" | "all";
|
||||
};
|
||||
|
||||
export type InvoiceBindPayload =
|
||||
| {
|
||||
reimbursementId: string;
|
||||
linkedItemId?: string;
|
||||
}
|
||||
| {
|
||||
travelOrderId: string;
|
||||
};
|
||||
|
||||
function toQueryString(query: Record<string, string | number | undefined>) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -147,10 +158,19 @@ export async function uploadInvoice(file: File, ocrType?: string, ownerEmployeeI
|
||||
);
|
||||
}
|
||||
|
||||
export async function bindInvoice(invoiceId: string, reimbursementId: string, linkedItemId?: string) {
|
||||
export async function bindInvoice(
|
||||
invoiceId: string,
|
||||
reimbursementIdOrPayload: string | InvoiceBindPayload,
|
||||
linkedItemId?: string,
|
||||
) {
|
||||
const payload =
|
||||
typeof reimbursementIdOrPayload === "string"
|
||||
? { reimbursementId: reimbursementIdOrPayload, linkedItemId }
|
||||
: reimbursementIdOrPayload;
|
||||
|
||||
return apiRequestData<Record<string, unknown>>(`/api/invoice-pool/${invoiceId}/bind`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reimbursementId, linkedItemId }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type TravelPaymentChannel =
|
||||
| "bank_transfer"
|
||||
| "corporate_card"
|
||||
| "virtual_card"
|
||||
| "manual"
|
||||
| "other";
|
||||
|
||||
export type TravelPaymentBudgetSummaryItem = {
|
||||
budgetPoolId: string;
|
||||
budgetPoolCode: string | null;
|
||||
budgetPoolName: string | null;
|
||||
outstanding: string;
|
||||
reserve?: string;
|
||||
confirm?: string;
|
||||
release?: string;
|
||||
confirmPending?: string;
|
||||
authorize?: string;
|
||||
settle?: string;
|
||||
reverse?: string;
|
||||
settlePending?: string;
|
||||
};
|
||||
|
||||
export type TravelPaymentAuthorization = {
|
||||
id: string;
|
||||
source: string;
|
||||
externalAuthNo: string;
|
||||
employeeId: string;
|
||||
departmentId: string | null;
|
||||
travelRequestId: string;
|
||||
budgetPoolId: string | null;
|
||||
legalEntityId: string;
|
||||
costCenterId: string;
|
||||
authorizedAmount: string;
|
||||
settledAmount: string;
|
||||
reversedAmount: string;
|
||||
currency: string;
|
||||
occurredAt: string | null;
|
||||
expiresAt: string | null;
|
||||
status: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type TravelPaymentMatchedOrder = {
|
||||
id: string;
|
||||
externalOrderNo: string;
|
||||
status: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
exactMatch: boolean;
|
||||
};
|
||||
|
||||
export type TravelPaymentTransaction = {
|
||||
id: string;
|
||||
source: string;
|
||||
externalTxnId: string;
|
||||
cardAuthorizationId: string | null;
|
||||
employeeId: string;
|
||||
departmentId: string | null;
|
||||
travelRequestId: string;
|
||||
travelOrderId: string | null;
|
||||
amount: string;
|
||||
currency: string;
|
||||
txnType: string;
|
||||
status: string;
|
||||
occurredAt: string | null;
|
||||
freezeSuggestedAt: string | null;
|
||||
freezeSuggestedBy: string | null;
|
||||
freezeReasonCode: string | null;
|
||||
freezeReasonNote: string | null;
|
||||
matchedOrder: TravelPaymentMatchedOrder | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type TravelPaymentBoundInvoice = {
|
||||
id: string;
|
||||
status: string;
|
||||
originalName: string | null;
|
||||
totalAmount: string | null;
|
||||
invoiceCode: string | null;
|
||||
invoiceNumber: string | null;
|
||||
invoiceDate: string | null;
|
||||
linkedReimbursementId: string | null;
|
||||
linkedTravelOrderId: string | null;
|
||||
};
|
||||
|
||||
export type TravelPaymentErpStage = {
|
||||
binding: {
|
||||
id: string;
|
||||
provider: string;
|
||||
entityType: string;
|
||||
localId: string;
|
||||
externalId: string | null;
|
||||
externalCode: string | null;
|
||||
lastSyncedAt: string | null;
|
||||
} | null;
|
||||
outbox: {
|
||||
id: string;
|
||||
eventType: string;
|
||||
status: string;
|
||||
lastError: string | null;
|
||||
processedAt: string | null;
|
||||
updatedAt: string | null;
|
||||
} | null;
|
||||
exportState: {
|
||||
externalId: string | null;
|
||||
lastOutboxId: string | null;
|
||||
lastOutboxStatus: string | null;
|
||||
lastError: string | null;
|
||||
exportedAt: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type TravelPaymentOrder = {
|
||||
id: string;
|
||||
externalOrderNo: string;
|
||||
source: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
status: string;
|
||||
paymentVoucherNo: string | null;
|
||||
paymentDate: string | null;
|
||||
paymentChannel: TravelPaymentChannel | null;
|
||||
paidAt: string | null;
|
||||
paidBy: string | null;
|
||||
boundInvoices: TravelPaymentBoundInvoice[];
|
||||
matchedTransactions: TravelPaymentTransaction[];
|
||||
erpStage: TravelPaymentErpStage;
|
||||
actions: {
|
||||
canMarkPaid: boolean;
|
||||
canBindInvoice: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type TravelPaymentAnomaly = {
|
||||
code: string;
|
||||
level: "low" | "medium" | "high";
|
||||
title: string;
|
||||
detail: string;
|
||||
transactionId: string;
|
||||
travelOrderId: string | null;
|
||||
};
|
||||
|
||||
export type TravelPaymentTrack = {
|
||||
travelRequestId: string;
|
||||
requestNo: string | null;
|
||||
requestBudgetTrack: {
|
||||
items: TravelPaymentBudgetSummaryItem[];
|
||||
};
|
||||
corporateBudgetTrack: {
|
||||
items: TravelPaymentBudgetSummaryItem[];
|
||||
};
|
||||
authorizations: TravelPaymentAuthorization[];
|
||||
transactions: TravelPaymentTransaction[];
|
||||
travelOrders: TravelPaymentOrder[];
|
||||
anomalies: TravelPaymentAnomaly[];
|
||||
permissions: {
|
||||
canManagePayments: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type TravelAuthorizationListResponse = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
items: TravelPaymentAuthorization[];
|
||||
};
|
||||
|
||||
export type TravelTransactionListResponse = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
items: TravelPaymentTransaction[];
|
||||
};
|
||||
|
||||
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 fetchTravelAuthorizations(query: Record<string, string | number | boolean | undefined> = {}) {
|
||||
return apiRequestData<TravelAuthorizationListResponse>(
|
||||
`/api/travel/authorizations${toQueryString(query)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function importTravelAuthorizations(payload: { authorizations: Array<Record<string, unknown>> }) {
|
||||
return apiRequestData<Record<string, unknown>>("/api/travel/authorizations/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchTravelTransactions(query: Record<string, string | number | boolean | undefined> = {}) {
|
||||
return apiRequestData<TravelTransactionListResponse>(
|
||||
`/api/travel/transactions${toQueryString(query)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function importTravelTransactions(payload: { transactions: Array<Record<string, unknown>> }) {
|
||||
return apiRequestData<Record<string, unknown>>("/api/travel/transactions/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function suggestFreezeTravelTransaction(
|
||||
transactionId: string,
|
||||
payload: {
|
||||
reasonCode: "amount_mismatch" | "currency_mismatch" | "missing_order" | "duplicate" | "manual_review";
|
||||
note?: string | null;
|
||||
},
|
||||
) {
|
||||
return apiRequestData<TravelPaymentTransaction>(`/api/travel/transactions/${transactionId}/freeze-suggestion`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function markTravelOrderPaid(
|
||||
travelOrderId: string,
|
||||
payload: {
|
||||
paymentVoucherNo?: string | null;
|
||||
paymentDate?: string | null;
|
||||
paymentChannel?: TravelPaymentChannel | null;
|
||||
},
|
||||
) {
|
||||
return apiRequestData<{
|
||||
travelOrderId: string;
|
||||
travelRequestId: string | null;
|
||||
status: string;
|
||||
paymentVoucherNo: string | null;
|
||||
paymentDate: string | null;
|
||||
paymentChannel: TravelPaymentChannel | null;
|
||||
erpJournal: {
|
||||
eventType: string;
|
||||
outboxId: string | null;
|
||||
dedupeKey: string;
|
||||
enqueued: boolean;
|
||||
};
|
||||
}>(`/api/travel-orders/${travelOrderId}/mark-paid`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchTravelPaymentTrack(travelRequestId: string) {
|
||||
return apiRequestData<TravelPaymentTrack>(`/api/travel-requests/${travelRequestId}/payment-track`);
|
||||
}
|
||||
@@ -73,10 +73,16 @@ export type TravelOrderLink = {
|
||||
externalOrderNo: string;
|
||||
employeeId: string;
|
||||
linkedReimbursementId: string | null;
|
||||
travelRequestId: string | null;
|
||||
occurredAt: string | null;
|
||||
amount: string | null;
|
||||
currency: string;
|
||||
status: string;
|
||||
paymentVoucherNo: string | null;
|
||||
paymentDate: string | null;
|
||||
paymentChannel: string | null;
|
||||
paidAt: string | null;
|
||||
paidBy: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
@@ -90,6 +90,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||
"expense.view_all",
|
||||
"procurement.view_all",
|
||||
"travel.view_all",
|
||||
"travel.payment.manage",
|
||||
"asset.view_all",
|
||||
],
|
||||
finance_mgr: [
|
||||
@@ -111,6 +112,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||
"procurement.manage",
|
||||
"travel.view_all",
|
||||
"travel.manage",
|
||||
"travel.payment.manage",
|
||||
"asset.view_all",
|
||||
"asset.manage",
|
||||
"asset.inventory",
|
||||
@@ -124,6 +126,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||
"procurement.manage",
|
||||
"travel.view_all",
|
||||
"travel.manage",
|
||||
"travel.payment.manage",
|
||||
"asset.view_all",
|
||||
"asset.manage",
|
||||
"asset.transfer",
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { TravelPaymentChannel, TravelPaymentOrder } from "../../api/travelPayments";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
|
||||
const PAYMENT_CHANNEL_OPTIONS: Array<{ value: TravelPaymentChannel; label: string }> = [
|
||||
{ value: "bank_transfer", label: "银行转账" },
|
||||
{ value: "corporate_card", label: "企业卡" },
|
||||
{ value: "virtual_card", label: "虚拟卡" },
|
||||
{ value: "manual", label: "手工录入" },
|
||||
{ value: "other", label: "其他" },
|
||||
];
|
||||
|
||||
export type TravelOrderMarkPaidDialogProps = {
|
||||
open: boolean;
|
||||
order: TravelPaymentOrder | null;
|
||||
pending?: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (payload: {
|
||||
paymentVoucherNo: string | null;
|
||||
paymentDate: string | null;
|
||||
paymentChannel: TravelPaymentChannel | null;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export function TravelOrderMarkPaidDialog({
|
||||
open,
|
||||
order,
|
||||
pending = false,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: TravelOrderMarkPaidDialogProps) {
|
||||
const [paymentVoucherNo, setPaymentVoucherNo] = useState("");
|
||||
const [paymentDate, setPaymentDate] = useState("");
|
||||
const [paymentChannel, setPaymentChannel] = useState<TravelPaymentChannel | "">("bank_transfer");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
setPaymentVoucherNo(order?.paymentVoucherNo ?? "");
|
||||
setPaymentDate(order?.paymentDate ?? "");
|
||||
setPaymentChannel((order?.paymentChannel as TravelPaymentChannel | null) ?? "bank_transfer");
|
||||
}, [open, order]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>标记差旅订单已支付</DialogTitle>
|
||||
<DialogDescription>
|
||||
确认订单 `{order?.externalOrderNo ?? "-"}` 已完成对公支付;系统会按幂等键复用现有 ERP journal 出站记录。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="travel-order-payment-voucher">付款凭证号</Label>
|
||||
<Input
|
||||
id="travel-order-payment-voucher"
|
||||
className="mt-1"
|
||||
placeholder="可留空"
|
||||
value={paymentVoucherNo}
|
||||
onChange={(event) => setPaymentVoucherNo(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="travel-order-payment-date">付款日期</Label>
|
||||
<Input
|
||||
id="travel-order-payment-date"
|
||||
className="mt-1"
|
||||
type="date"
|
||||
value={paymentDate}
|
||||
onChange={(event) => setPaymentDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="travel-order-payment-channel">付款渠道</Label>
|
||||
<select
|
||||
id="travel-order-payment-channel"
|
||||
className="select-std mt-1"
|
||||
value={paymentChannel}
|
||||
onChange={(event) => setPaymentChannel(event.target.value as TravelPaymentChannel | "")}
|
||||
>
|
||||
{PAYMENT_CHANNEL_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)} disabled={pending}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onConfirm({
|
||||
paymentVoucherNo: paymentVoucherNo.trim() || null,
|
||||
paymentDate: paymentDate || null,
|
||||
paymentChannel: paymentChannel || null,
|
||||
})
|
||||
}
|
||||
disabled={pending}
|
||||
>
|
||||
{pending ? "提交中..." : "确认已支付"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { mapApiErrorMessage } from "../../api/errorMessages";
|
||||
import {
|
||||
bindInvoice,
|
||||
fetchInvoicePoolList,
|
||||
type InvoicePoolListItem,
|
||||
} from "../../api/invoicePool";
|
||||
import { isApiError } from "../../api/types";
|
||||
import {
|
||||
fetchTravelPaymentTrack,
|
||||
markTravelOrderPaid,
|
||||
suggestFreezeTravelTransaction,
|
||||
type TravelPaymentAnomaly,
|
||||
type TravelPaymentChannel,
|
||||
type TravelPaymentOrder,
|
||||
} from "../../api/travelPayments";
|
||||
import { CardSkeleton } from "../common/CardSkeleton";
|
||||
import { StatusBadge } from "../common/StatusBadge";
|
||||
import { Button } from "../ui/button";
|
||||
import { TravelOrderMarkPaidDialog } from "./TravelOrderMarkPaidDialog";
|
||||
|
||||
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 freezeReasonFromAnomaly(code: string): "amount_mismatch" | "currency_mismatch" | "missing_order" | "duplicate" | "manual_review" {
|
||||
if (code === "amount_mismatch") return "amount_mismatch";
|
||||
if (code === "currency_mismatch") return "currency_mismatch";
|
||||
if (code === "missing_order") return "missing_order";
|
||||
if (code === "duplicate") return "duplicate";
|
||||
return "manual_review";
|
||||
}
|
||||
|
||||
function anomalyTone(level: TravelPaymentAnomaly["level"]) {
|
||||
if (level === "high") {
|
||||
return "border-red-200 bg-red-50 text-red-700";
|
||||
}
|
||||
if (level === "medium") {
|
||||
return "border-amber-200 bg-amber-50 text-amber-700";
|
||||
}
|
||||
return "border-zinc-200 bg-zinc-50 text-zinc-700";
|
||||
}
|
||||
|
||||
function paymentChannelLabel(value: TravelPaymentChannel | string | null) {
|
||||
if (value === "bank_transfer") return "银行转账";
|
||||
if (value === "corporate_card") return "企业卡";
|
||||
if (value === "virtual_card") return "虚拟卡";
|
||||
if (value === "manual") return "手工录入";
|
||||
if (value === "other") return "其他";
|
||||
return "-";
|
||||
}
|
||||
|
||||
function erpStageLabel(order: TravelPaymentOrder) {
|
||||
const status = order.erpStage.exportState.lastOutboxStatus;
|
||||
if (status) {
|
||||
return status;
|
||||
}
|
||||
if (order.erpStage.binding?.externalId) {
|
||||
return "已同步";
|
||||
}
|
||||
return "ERP 出站未开启";
|
||||
}
|
||||
|
||||
type TravelPaymentTrackCardProps = {
|
||||
travelRequestId: string;
|
||||
};
|
||||
|
||||
export function TravelPaymentTrackCard({ travelRequestId }: TravelPaymentTrackCardProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [markPaidOrder, setMarkPaidOrder] = useState<TravelPaymentOrder | null>(null);
|
||||
const [selectedInvoiceMap, setSelectedInvoiceMap] = useState<Record<string, string>>({});
|
||||
|
||||
const trackQuery = useQuery({
|
||||
enabled: Boolean(travelRequestId),
|
||||
queryKey: ["travel-requests", "payment-track", travelRequestId],
|
||||
queryFn: () => fetchTravelPaymentTrack(travelRequestId),
|
||||
});
|
||||
|
||||
const availableInvoicesQuery = useQuery({
|
||||
enabled: trackQuery.data?.permissions.canManagePayments === true,
|
||||
queryKey: ["invoice-pool", "available-for-travel-bind"],
|
||||
queryFn: () =>
|
||||
fetchInvoicePoolList({
|
||||
status: "available",
|
||||
ownerScope: "all",
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
}),
|
||||
});
|
||||
|
||||
const markPaidMutation = useMutation({
|
||||
mutationFn: ({ orderId, payload }: { orderId: string; payload: { paymentVoucherNo: string | null; paymentDate: string | null; paymentChannel: TravelPaymentChannel | null } }) =>
|
||||
markTravelOrderPaid(orderId, payload),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["travel-requests"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }),
|
||||
]);
|
||||
toast.success("差旅订单已标记为已支付");
|
||||
setMarkPaidOrder(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveError(error));
|
||||
},
|
||||
});
|
||||
|
||||
const bindInvoiceMutation = useMutation({
|
||||
mutationFn: ({ invoiceId, orderId }: { invoiceId: string; orderId: string }) =>
|
||||
bindInvoice(invoiceId, { travelOrderId: orderId }),
|
||||
onSuccess: async (_, variables) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["travel-requests"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }),
|
||||
]);
|
||||
setSelectedInvoiceMap((current) => ({ ...current, [variables.orderId]: "" }));
|
||||
toast.success("发票已绑定到差旅订单");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveError(error));
|
||||
},
|
||||
});
|
||||
|
||||
const freezeMutation = useMutation({
|
||||
mutationFn: ({ transactionId, reasonCode }: { transactionId: string; reasonCode: "amount_mismatch" | "currency_mismatch" | "missing_order" | "duplicate" | "manual_review" }) =>
|
||||
suggestFreezeTravelTransaction(transactionId, { reasonCode, note: null }),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["travel-requests", "payment-track", travelRequestId] });
|
||||
toast.success("已记录冻结建议");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveError(error));
|
||||
},
|
||||
});
|
||||
|
||||
const anomaliesByTransactionId = useMemo(() => {
|
||||
const map = new Map<string, TravelPaymentAnomaly[]>();
|
||||
for (const item of trackQuery.data?.anomalies ?? []) {
|
||||
const list = map.get(item.transactionId) ?? [];
|
||||
list.push(item);
|
||||
map.set(item.transactionId, list);
|
||||
}
|
||||
return map;
|
||||
}, [trackQuery.data?.anomalies]);
|
||||
|
||||
const availableInvoices = availableInvoicesQuery.data?.items ?? [];
|
||||
const canManagePayments = trackQuery.data?.permissions.canManagePayments ?? false;
|
||||
|
||||
if (trackQuery.isLoading) {
|
||||
return <CardSkeleton lines={8} />;
|
||||
}
|
||||
|
||||
if (trackQuery.isError) {
|
||||
return (
|
||||
<div className="card-std p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">对公支付轨道</h3>
|
||||
<p className="mt-3 text-sm text-zinc-500">支付轨道加载失败,但不影响差旅主页面继续使用。</p>
|
||||
<p className="mt-2 text-sm text-red-600">{resolveError(trackQuery.error)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const track = trackQuery.data;
|
||||
if (!track) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card-std space-y-5 p-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">对公支付轨道</h3>
|
||||
<p className="mt-1 text-sm text-zinc-500">授权、交易、发票绑定与 ERP 支付出站在此统一查看。</p>
|
||||
</div>
|
||||
{canManagePayments ? <span className="text-xs text-zinc-500">具备支付管理权限</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<div className="rounded-xl border border-zinc-200 p-4">
|
||||
<h4 className="text-sm font-semibold text-zinc-900">申请预算账轨</h4>
|
||||
{track.requestBudgetTrack.items.length ? (
|
||||
<div className="mt-3 space-y-2 text-sm text-zinc-600">
|
||||
{track.requestBudgetTrack.items.map((item) => (
|
||||
<div key={item.budgetPoolId} className="rounded-lg bg-zinc-50 px-3 py-2">
|
||||
<div className="font-medium text-zinc-900">{item.budgetPoolName ?? item.budgetPoolCode ?? item.budgetPoolId}</div>
|
||||
<div className="mt-1 text-xs">reserve {item.reserve ?? "0.00"} · confirm {item.confirm ?? "0.00"} · release {item.release ?? "0.00"}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-zinc-500">当前没有申请预算流水。</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-zinc-200 p-4">
|
||||
<h4 className="text-sm font-semibold text-zinc-900">支付预算账轨</h4>
|
||||
{track.corporateBudgetTrack.items.length ? (
|
||||
<div className="mt-3 space-y-2 text-sm text-zinc-600">
|
||||
{track.corporateBudgetTrack.items.map((item) => (
|
||||
<div key={item.budgetPoolId} className="rounded-lg bg-zinc-50 px-3 py-2">
|
||||
<div className="font-medium text-zinc-900">{item.budgetPoolName ?? item.budgetPoolCode ?? item.budgetPoolId}</div>
|
||||
<div className="mt-1 text-xs">authorize {item.authorize ?? "0.00"} · settle {item.settle ?? "0.00"} · reverse {item.reverse ?? "0.00"}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-zinc-500">当前没有企业卡/支付预算流水。</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{track.authorizations.length ? (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-zinc-900">企业卡授权</h4>
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
{track.authorizations.map((item) => (
|
||||
<div key={item.id} className="rounded-xl border border-zinc-200 p-4 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-medium text-zinc-900">{item.externalAuthNo}</div>
|
||||
<StatusBadge status={item.status} />
|
||||
</div>
|
||||
<div className="mt-2 space-y-1 text-xs text-zinc-500">
|
||||
<div>来源:{item.source}</div>
|
||||
<div>授权金额:{item.authorizedAmount} {item.currency}</div>
|
||||
<div>已结算:{item.settledAmount} · 已冲回:{item.reversedAmount}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-zinc-900">差旅订单支付闭环</h4>
|
||||
{track.travelOrders.length ? (
|
||||
<div className="space-y-4">
|
||||
{track.travelOrders.map((order) => {
|
||||
const selectedInvoiceId = selectedInvoiceMap[order.id] ?? "";
|
||||
return (
|
||||
<div key={order.id} className="rounded-xl border border-zinc-200 p-4">
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="font-medium text-zinc-900">{order.externalOrderNo}</p>
|
||||
<StatusBadge status={order.status} />
|
||||
</div>
|
||||
<div className="mt-2 space-y-1 text-xs text-zinc-500">
|
||||
<div>来源:{order.source}</div>
|
||||
<div>金额:{order.amount} {order.currency}</div>
|
||||
<div>付款凭证:{order.paymentVoucherNo ?? "-"}</div>
|
||||
<div>付款日期:{order.paymentDate ?? "-"} · 渠道:{paymentChannelLabel(order.paymentChannel)}</div>
|
||||
<div>ERP 状态:{erpStageLabel(order)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{canManagePayments ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{order.actions.canMarkPaid ? (
|
||||
<Button type="button" variant="secondary" onClick={() => setMarkPaidOrder(order)}>
|
||||
标记已支付
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 xl:grid-cols-2">
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="font-medium text-zinc-900">已匹配交易</div>
|
||||
{order.matchedTransactions.length ? (
|
||||
order.matchedTransactions.map((transaction) => {
|
||||
const transactionAnomalies = anomaliesByTransactionId.get(transaction.id) ?? [];
|
||||
return (
|
||||
<div key={transaction.id} className="rounded-lg bg-zinc-50 px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-medium text-zinc-900">{transaction.externalTxnId}</div>
|
||||
<StatusBadge status={transaction.status} />
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{transaction.amount} {transaction.currency} · {transaction.txnType}
|
||||
</div>
|
||||
{transaction.freezeReasonCode ? (
|
||||
<div className="mt-2 text-xs text-amber-700">已建议冻结:{transaction.freezeReasonCode}</div>
|
||||
) : null}
|
||||
{canManagePayments && transactionAnomalies.length && !transaction.freezeReasonCode ? (
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={freezeMutation.isPending}
|
||||
onClick={() =>
|
||||
freezeMutation.mutate({
|
||||
transactionId: transaction.id,
|
||||
reasonCode: freezeReasonFromAnomaly(transactionAnomalies[0].code),
|
||||
})
|
||||
}
|
||||
>
|
||||
{freezeMutation.isPending ? "提交中..." : "建议冻结"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">当前没有匹配到已导入交易。</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="font-medium text-zinc-900">已绑发票</div>
|
||||
{order.boundInvoices.length ? (
|
||||
order.boundInvoices.map((invoice) => (
|
||||
<div key={invoice.id} className="rounded-lg bg-zinc-50 px-3 py-3 text-xs text-zinc-600">
|
||||
<div className="font-medium text-zinc-900">{invoice.invoiceCode ?? invoice.originalName ?? invoice.id}</div>
|
||||
<div className="mt-1">金额:{invoice.totalAmount ?? "-"}</div>
|
||||
<div>票号:{invoice.invoiceNumber ?? "-"}</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">当前没有已绑定发票。</p>
|
||||
)}
|
||||
|
||||
{canManagePayments && order.actions.canBindInvoice ? (
|
||||
<div className="rounded-lg border border-dashed border-zinc-200 p-3">
|
||||
{availableInvoicesQuery.isError ? (
|
||||
<p className="text-xs text-zinc-500">可用发票列表暂不可用,可去发票池页面完成绑定。</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
className="select-std"
|
||||
value={selectedInvoiceId}
|
||||
onChange={(event) =>
|
||||
setSelectedInvoiceMap((current) => ({
|
||||
...current,
|
||||
[order.id]: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">选择可用发票</option>
|
||||
{availableInvoices.map((invoice: InvoicePoolListItem) => (
|
||||
<option key={invoice.id} value={invoice.id}>
|
||||
{invoice.invoiceCode ?? invoice.originalName ?? invoice.id} ({invoice.totalAmount ?? "-"})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={bindInvoiceMutation.isPending || !selectedInvoiceId}
|
||||
onClick={() => bindInvoiceMutation.mutate({ invoiceId: selectedInvoiceId, orderId: order.id })}
|
||||
>
|
||||
{bindInvoiceMutation.isPending ? "绑定中..." : "绑定发票"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">当前差旅申请还没有进入对公支付闭环。</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{track.anomalies.length ? (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-zinc-900">异常提示</h4>
|
||||
<div className="space-y-2">
|
||||
{track.anomalies.map((item) => (
|
||||
<div key={`${item.transactionId}-${item.code}`} className={`rounded-lg border px-3 py-3 text-sm ${anomalyTone(item.level)}`}>
|
||||
<div className="font-medium">{item.title}</div>
|
||||
<div className="mt-1 text-xs">{item.detail}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<TravelOrderMarkPaidDialog
|
||||
open={Boolean(markPaidOrder)}
|
||||
order={markPaidOrder}
|
||||
pending={markPaidMutation.isPending}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setMarkPaidOrder(null);
|
||||
}
|
||||
}}
|
||||
onConfirm={(payload) => {
|
||||
if (!markPaidOrder) {
|
||||
return;
|
||||
}
|
||||
markPaidMutation.mutate({ orderId: markPaidOrder.id, payload });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
在新工单中引用
屏蔽一个用户