feat(P20T2-4): 差旅支付轨道卡片与发票绑定前端交互
这个提交包含在:
@@ -34,6 +34,7 @@ export type InvoicePoolListItem = {
|
|||||||
totalAmount: string | null;
|
totalAmount: string | null;
|
||||||
linkedReimbursementId: string | null;
|
linkedReimbursementId: string | null;
|
||||||
linkedItemId: string | null;
|
linkedItemId: string | null;
|
||||||
|
linkedTravelOrderId: string | null;
|
||||||
originalName: string;
|
originalName: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
fileSize: number;
|
fileSize: number;
|
||||||
@@ -88,6 +89,7 @@ export type InvoicePoolDetail = {
|
|||||||
totalAmount: string | null;
|
totalAmount: string | null;
|
||||||
linkedReimbursementId: string | null;
|
linkedReimbursementId: string | null;
|
||||||
linkedItemId: string | null;
|
linkedItemId: string | null;
|
||||||
|
linkedTravelOrderId: string | null;
|
||||||
isDuplicateOverride: boolean;
|
isDuplicateOverride: boolean;
|
||||||
overrideRefFormNumber: string | null;
|
overrideRefFormNumber: string | null;
|
||||||
overrideReason: string | null;
|
overrideReason: string | null;
|
||||||
@@ -104,6 +106,15 @@ export type InvoicePoolListQuery = {
|
|||||||
ownerScope?: "own" | "all";
|
ownerScope?: "own" | "all";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type InvoiceBindPayload =
|
||||||
|
| {
|
||||||
|
reimbursementId: string;
|
||||||
|
linkedItemId?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
travelOrderId: string;
|
||||||
|
};
|
||||||
|
|
||||||
function toQueryString(query: Record<string, string | number | undefined>) {
|
function toQueryString(query: Record<string, string | number | undefined>) {
|
||||||
const params = new URLSearchParams();
|
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`, {
|
return apiRequestData<Record<string, unknown>>(`/api/invoice-pool/${invoiceId}/bind`, {
|
||||||
method: "POST",
|
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;
|
externalOrderNo: string;
|
||||||
employeeId: string;
|
employeeId: string;
|
||||||
linkedReimbursementId: string | null;
|
linkedReimbursementId: string | null;
|
||||||
|
travelRequestId: string | null;
|
||||||
occurredAt: string | null;
|
occurredAt: string | null;
|
||||||
amount: string | null;
|
amount: string | null;
|
||||||
currency: string;
|
currency: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
paymentVoucherNo: string | null;
|
||||||
|
paymentDate: string | null;
|
||||||
|
paymentChannel: string | null;
|
||||||
|
paidAt: string | null;
|
||||||
|
paidBy: string | null;
|
||||||
createdAt: string | null;
|
createdAt: string | null;
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
|||||||
"expense.view_all",
|
"expense.view_all",
|
||||||
"procurement.view_all",
|
"procurement.view_all",
|
||||||
"travel.view_all",
|
"travel.view_all",
|
||||||
|
"travel.payment.manage",
|
||||||
"asset.view_all",
|
"asset.view_all",
|
||||||
],
|
],
|
||||||
finance_mgr: [
|
finance_mgr: [
|
||||||
@@ -111,6 +112,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
|||||||
"procurement.manage",
|
"procurement.manage",
|
||||||
"travel.view_all",
|
"travel.view_all",
|
||||||
"travel.manage",
|
"travel.manage",
|
||||||
|
"travel.payment.manage",
|
||||||
"asset.view_all",
|
"asset.view_all",
|
||||||
"asset.manage",
|
"asset.manage",
|
||||||
"asset.inventory",
|
"asset.inventory",
|
||||||
@@ -124,6 +126,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
|||||||
"procurement.manage",
|
"procurement.manage",
|
||||||
"travel.view_all",
|
"travel.view_all",
|
||||||
"travel.manage",
|
"travel.manage",
|
||||||
|
"travel.payment.manage",
|
||||||
"asset.view_all",
|
"asset.view_all",
|
||||||
"asset.manage",
|
"asset.manage",
|
||||||
"asset.transfer",
|
"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,
|
type InvoicePoolStatus,
|
||||||
} from "../api/invoicePool";
|
} from "../api/invoicePool";
|
||||||
import { fetchReimbursements } from "../api/reimbursements";
|
import { fetchReimbursements } from "../api/reimbursements";
|
||||||
|
import { fetchTravelRequestDetail, fetchTravelRequests } from "../api/travelRequests";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasPermission } from "../auth/permissions";
|
import { hasPermission } from "../auth/permissions";
|
||||||
import { toast } from "sonner";
|
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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
|
||||||
import { InvoiceUploadCard } from "./invoice-pool/InvoiceUploadCard";
|
import { InvoiceUploadCard } from "./invoice-pool/InvoiceUploadCard";
|
||||||
import { InvoiceListCard } from "./invoice-pool/InvoiceListCard";
|
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";
|
import { InvoiceDetailPanel } from "./invoice-pool/InvoiceDetailPanel";
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
@@ -43,6 +44,7 @@ export function InvoicePoolPage() {
|
|||||||
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
|
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
|
||||||
const canVerifyInvoice = hasPermission(permissions, "invoice.verify");
|
const canVerifyInvoice = hasPermission(permissions, "invoice.verify");
|
||||||
const canProxySubmit = hasPermission(permissions, "expense.proxy_submit");
|
const canProxySubmit = hasPermission(permissions, "expense.proxy_submit");
|
||||||
|
const canManageTravelPayments = hasPermission(permissions, "travel.payment.manage");
|
||||||
const isAdministration = (me?.roleCodes ?? []).includes("administration");
|
const isAdministration = (me?.roleCodes ?? []).includes("administration");
|
||||||
const showProxyOwnerSelector = canProxySubmit && isAdministration;
|
const showProxyOwnerSelector = canProxySubmit && isAdministration;
|
||||||
const [status, setStatus] = useState<InvoicePoolStatus | "">("");
|
const [status, setStatus] = useState<InvoicePoolStatus | "">("");
|
||||||
@@ -51,7 +53,10 @@ export function InvoicePoolPage() {
|
|||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const [uploadOwnerEmployeeId, setUploadOwnerEmployeeId] = useState("");
|
const [uploadOwnerEmployeeId, setUploadOwnerEmployeeId] = useState("");
|
||||||
const [selectedInvoiceId, setSelectedInvoiceId] = useState("");
|
const [selectedInvoiceId, setSelectedInvoiceId] = useState("");
|
||||||
|
const [bindMode, setBindMode] = useState<InvoiceBindMode>("reimbursement");
|
||||||
const [selectedReimbursementId, setSelectedReimbursementId] = useState("");
|
const [selectedReimbursementId, setSelectedReimbursementId] = useState("");
|
||||||
|
const [selectedTravelRequestId, setSelectedTravelRequestId] = useState("");
|
||||||
|
const [selectedTravelOrderId, setSelectedTravelOrderId] = useState("");
|
||||||
const [detailInvoiceId, setDetailInvoiceId] = useState("");
|
const [detailInvoiceId, setDetailInvoiceId] = useState("");
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [editSetAvailable, setEditSetAvailable] = useState(false);
|
const [editSetAvailable, setEditSetAvailable] = useState(false);
|
||||||
@@ -81,6 +86,18 @@ export function InvoicePoolPage() {
|
|||||||
queryFn: () => fetchReimbursements({ page: 1, pageSize: 50, scope: "own" }),
|
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({
|
const proxyDelegationsQuery = useQuery({
|
||||||
enabled: showProxyOwnerSelector,
|
enabled: showProxyOwnerSelector,
|
||||||
queryKey: ["delegations", "mine", "active"],
|
queryKey: ["delegations", "mine", "active"],
|
||||||
@@ -132,6 +149,15 @@ export function InvoicePoolPage() {
|
|||||||
}
|
}
|
||||||
}, [canManageInvoice, ownerScope]);
|
}, [canManageInvoice, ownerScope]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (canManageTravelPayments) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBindMode("reimbursement");
|
||||||
|
setSelectedTravelRequestId("");
|
||||||
|
setSelectedTravelOrderId("");
|
||||||
|
}, [canManageTravelPayments]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!detailInvoiceId && listQuery.data?.items?.length) {
|
if (!detailInvoiceId && listQuery.data?.items?.length) {
|
||||||
setDetailInvoiceId(listQuery.data.items[0].id);
|
setDetailInvoiceId(listQuery.data.items[0].id);
|
||||||
@@ -169,14 +195,26 @@ export function InvoicePoolPage() {
|
|||||||
|
|
||||||
const bindMutation = useMutation({
|
const bindMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!selectedInvoiceId || !selectedReimbursementId) {
|
if (!selectedInvoiceId) {
|
||||||
throw new Error("请选择票据与报销单");
|
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 () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||||||
setError("");
|
setError("");
|
||||||
|
setSelectedInvoiceId("");
|
||||||
|
setSelectedReimbursementId("");
|
||||||
|
setSelectedTravelOrderId("");
|
||||||
toast.success("绑定成功");
|
toast.success("绑定成功");
|
||||||
},
|
},
|
||||||
onError: (err) => handleError(err, "绑定失败,请稍后重试"),
|
onError: (err) => handleError(err, "绑定失败,请稍后重试"),
|
||||||
@@ -380,11 +418,20 @@ export function InvoicePoolPage() {
|
|||||||
<InvoiceBindCard
|
<InvoiceBindCard
|
||||||
listQuery={listQuery}
|
listQuery={listQuery}
|
||||||
reimbursementQuery={reimbursementQuery}
|
reimbursementQuery={reimbursementQuery}
|
||||||
|
travelRequestQuery={travelRequestQuery}
|
||||||
|
travelRequestDetailQuery={travelRequestDetailQuery}
|
||||||
bindMutation={bindMutation}
|
bindMutation={bindMutation}
|
||||||
|
bindMode={bindMode}
|
||||||
|
setBindMode={setBindMode}
|
||||||
|
canManageTravelPayments={canManageTravelPayments}
|
||||||
selectedInvoiceId={selectedInvoiceId}
|
selectedInvoiceId={selectedInvoiceId}
|
||||||
setSelectedInvoiceId={setSelectedInvoiceId}
|
setSelectedInvoiceId={setSelectedInvoiceId}
|
||||||
selectedReimbursementId={selectedReimbursementId}
|
selectedReimbursementId={selectedReimbursementId}
|
||||||
setSelectedReimbursementId={setSelectedReimbursementId}
|
setSelectedReimbursementId={setSelectedReimbursementId}
|
||||||
|
selectedTravelRequestId={selectedTravelRequestId}
|
||||||
|
setSelectedTravelRequestId={setSelectedTravelRequestId}
|
||||||
|
selectedTravelOrderId={selectedTravelOrderId}
|
||||||
|
setSelectedTravelOrderId={setSelectedTravelOrderId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InvoiceDetailPanel
|
<InvoiceDetailPanel
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { Pagination } from "../components/common/Pagination";
|
|||||||
import { StatusBadge } from "../components/common/StatusBadge";
|
import { StatusBadge } from "../components/common/StatusBadge";
|
||||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
|
import { TravelPaymentTrackCard } from "../components/travel/TravelPaymentTrackCard";
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
import { Label } from "../components/ui/label";
|
import { Label } from "../components/ui/label";
|
||||||
import { Textarea } from "../components/ui/textarea";
|
import { Textarea } from "../components/ui/textarea";
|
||||||
@@ -723,6 +724,7 @@ export function TravelRequestsPage() {
|
|||||||
<div>发生日期:{item.occurredAt ?? "-"}</div>
|
<div>发生日期:{item.occurredAt ?? "-"}</div>
|
||||||
<div>金额:{item.amount ?? "-"} {item.currency}</div>
|
<div>金额:{item.amount ?? "-"} {item.currency}</div>
|
||||||
<div>报销关联:{item.linkedReimbursementId ?? "-"}</div>
|
<div>报销关联:{item.linkedReimbursementId ?? "-"}</div>
|
||||||
|
<div>付款渠道:{item.paymentChannel ?? "-"} · 付款日期:{item.paymentDate ?? "-"}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -732,6 +734,8 @@ export function TravelRequestsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TravelPaymentTrackCard travelRequestId={detail.id} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,30 +1,68 @@
|
|||||||
import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query";
|
import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query";
|
||||||
import type { InvoicePoolListResponse } from "../../api/invoicePool";
|
import type { InvoicePoolListResponse } from "../../api/invoicePool";
|
||||||
import type { ReimbursementListResponse } from "../../api/reimbursements";
|
import type { ReimbursementListResponse } from "../../api/reimbursements";
|
||||||
|
import type { TravelRequestDetail, TravelRequestListResponse } from "../../api/travelRequests";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
|
|
||||||
|
export type InvoiceBindMode = "reimbursement" | "travelOrder";
|
||||||
|
|
||||||
export type InvoiceBindCardProps = {
|
export type InvoiceBindCardProps = {
|
||||||
listQuery: UseQueryResult<InvoicePoolListResponse, Error>;
|
listQuery: UseQueryResult<InvoicePoolListResponse, Error>;
|
||||||
reimbursementQuery: UseQueryResult<ReimbursementListResponse, Error>;
|
reimbursementQuery: UseQueryResult<ReimbursementListResponse, Error>;
|
||||||
|
travelRequestQuery: UseQueryResult<TravelRequestListResponse, Error>;
|
||||||
|
travelRequestDetailQuery: UseQueryResult<TravelRequestDetail, Error>;
|
||||||
bindMutation: UseMutationResult<Record<string, unknown>, Error, void>;
|
bindMutation: UseMutationResult<Record<string, unknown>, Error, void>;
|
||||||
|
bindMode: InvoiceBindMode;
|
||||||
|
setBindMode: (mode: InvoiceBindMode) => void;
|
||||||
|
canManageTravelPayments: boolean;
|
||||||
selectedInvoiceId: string;
|
selectedInvoiceId: string;
|
||||||
setSelectedInvoiceId: (id: string) => void;
|
setSelectedInvoiceId: (id: string) => void;
|
||||||
selectedReimbursementId: string;
|
selectedReimbursementId: string;
|
||||||
setSelectedReimbursementId: (id: string) => void;
|
setSelectedReimbursementId: (id: string) => void;
|
||||||
|
selectedTravelRequestId: string;
|
||||||
|
setSelectedTravelRequestId: (id: string) => void;
|
||||||
|
selectedTravelOrderId: string;
|
||||||
|
setSelectedTravelOrderId: (id: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function InvoiceBindCard({
|
export function InvoiceBindCard({
|
||||||
listQuery,
|
listQuery,
|
||||||
reimbursementQuery,
|
reimbursementQuery,
|
||||||
|
travelRequestQuery,
|
||||||
|
travelRequestDetailQuery,
|
||||||
bindMutation,
|
bindMutation,
|
||||||
|
bindMode,
|
||||||
|
setBindMode,
|
||||||
|
canManageTravelPayments,
|
||||||
selectedInvoiceId,
|
selectedInvoiceId,
|
||||||
setSelectedInvoiceId,
|
setSelectedInvoiceId,
|
||||||
selectedReimbursementId,
|
selectedReimbursementId,
|
||||||
setSelectedReimbursementId,
|
setSelectedReimbursementId,
|
||||||
|
selectedTravelRequestId,
|
||||||
|
setSelectedTravelRequestId,
|
||||||
|
selectedTravelOrderId,
|
||||||
|
setSelectedTravelOrderId,
|
||||||
}: InvoiceBindCardProps) {
|
}: InvoiceBindCardProps) {
|
||||||
|
const bindableTravelOrders = (travelRequestDetailQuery.data?.travelOrders ?? []).filter((item) =>
|
||||||
|
["confirmed", "paid"].includes(item.status),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card-std p-6 space-y-4">
|
<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
|
<select
|
||||||
className="select-std"
|
className="select-std"
|
||||||
data-testid="invoice-bind-invoice"
|
data-testid="invoice-bind-invoice"
|
||||||
@@ -39,25 +77,65 @@ export function InvoiceBindCard({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select
|
{bindMode === "reimbursement" ? (
|
||||||
className="select-std"
|
<select
|
||||||
data-testid="invoice-bind-reimbursement"
|
className="select-std"
|
||||||
onChange={(event) => setSelectedReimbursementId(event.target.value)}
|
data-testid="invoice-bind-reimbursement"
|
||||||
value={selectedReimbursementId}
|
onChange={(event) => setSelectedReimbursementId(event.target.value)}
|
||||||
>
|
value={selectedReimbursementId}
|
||||||
<option value="">选择报销单</option>
|
>
|
||||||
{(reimbursementQuery.data?.items ?? []).map((item) => (
|
<option value="">选择报销单</option>
|
||||||
<option key={item.id} value={item.id}>
|
{(reimbursementQuery.data?.items ?? []).map((item) => (
|
||||||
{item.formNumber ?? item.id} ({item.status})
|
<option key={item.id} value={item.id}>
|
||||||
</option>
|
{item.formNumber ?? item.id} ({item.status})
|
||||||
))}
|
</option>
|
||||||
</select>
|
))}
|
||||||
|
</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">
|
<div className="pt-2">
|
||||||
<Button
|
<Button
|
||||||
data-testid="invoice-bind-submit"
|
data-testid="invoice-bind-submit"
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
disabled={bindMutation.isPending || !selectedInvoiceId || !selectedReimbursementId}
|
disabled={
|
||||||
|
bindMutation.isPending ||
|
||||||
|
!selectedInvoiceId ||
|
||||||
|
(bindMode === "reimbursement" ? !selectedReimbursementId : !selectedTravelOrderId)
|
||||||
|
}
|
||||||
onClick={() => bindMutation.mutate()}
|
onClick={() => bindMutation.mutate()}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户