feat(reimbursement-small-team-form-validation): 完善付款批次核对与回单预览

这个提交包含在:
2026-08-05 01:02:51 +08:00
父节点 9db8bbc338
当前提交 d4400116c5
共修改 24 个文件,包含 979 行新增和 245 行删除
+17
查看文件
@@ -25,6 +25,15 @@ export type PaymentBatchItem = {
failureMessage: string | null;
createdAt: string | null;
openRiskFlags: RiskFlag[];
expenseItems?: Array<{
id: string;
expenseTypeName: string;
expenseDate: string | null;
description: string;
amount: string;
}>;
expenseItemsTotal?: string;
expenseItemsAmountMatches?: boolean;
};
export type PaymentBatch = {
@@ -235,6 +244,14 @@ export async function downloadPaymentBatchReceiptAttachment(
return apiRequestBlob(buildAttachmentFileUrl(paymentBatchReceiptAttachmentFileUrl(batchId, attachmentId), reason));
}
export async function previewPaymentBatchReceiptAttachment(
batchId: string,
attachmentId: string,
): Promise<ApiBlobResponse> {
const params = new URLSearchParams({ mode: "preview" });
return apiRequestBlob(`${paymentBatchReceiptAttachmentFileUrl(batchId, attachmentId)}?${params.toString()}`);
}
export async function voidPaymentBatchReceiptAttachment(batchId: string, attachmentId: string, reason: string) {
return apiRequestData<{ attachmentId: string; outcome: "voided"; voidedAt: string }>(
`/api/payment-batches/${batchId}/receipt-attachments/${attachmentId}/void`,
@@ -40,7 +40,7 @@ export function AttachmentGovernanceBadges({
<span className={sensitivityClass(attachment.sensitivityLevel)}>
{SENSITIVITY_LEVEL_LABEL[attachment.sensitivityLevel] ?? attachment.sensitivityLevel}
</span>
{attachmentRequiresDownloadReason(attachment) ? <span className="badge-warning">需填用途</span> : null}
{attachmentRequiresDownloadReason(attachment) ? <span className="badge-warning">下载需说明用途</span> : null}
</div>
);
}
@@ -14,6 +14,7 @@ type Props = {
description: string | null;
editable: boolean;
mobile?: boolean;
showDescription?: boolean;
};
export function ReimbursementHeaderEditor({
@@ -22,14 +23,18 @@ export function ReimbursementHeaderEditor({
description,
editable,
mobile = false,
showDescription = true,
}: Props) {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [date, setDate] = useState(reimbursementDate ?? "");
const [reason, setReason] = useState(description ?? "");
const [error, setError] = useState("");
const dirty = editing && (date !== (reimbursementDate ?? "") || reason !== (description ?? ""));
const runGuarded = useUnsavedChangesGuard(dirty, "报销事由和日期");
const dirty = editing && (
date !== (reimbursementDate ?? "") ||
(showDescription && reason !== (description ?? ""))
);
const runGuarded = useUnsavedChangesGuard(dirty, showDescription ? "报销事由和日期" : "报销日期");
useEffect(() => {
if (editing) return;
@@ -40,7 +45,7 @@ export function ReimbursementHeaderEditor({
const mutation = useMutation({
mutationFn: () => updateReimbursement(reimbursementId, {
reimbursementDate: date,
description: reason.trim() || null,
...(showDescription ? { description: reason.trim() || null } : {}),
}),
onSuccess: async () => {
setError("");
@@ -59,16 +64,19 @@ export function ReimbursementHeaderEditor({
});
if (!editing) {
if (!showDescription && !editable) return null;
return (
<div className={mobile ? "mt-3" : "mt-3 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2"}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 text-sm leading-6 text-zinc-600">
<span className="font-medium text-zinc-800">报销事由:</span>
{description || "未填写"}
</div>
{showDescription ? (
<div className="min-w-0 text-sm leading-6 text-zinc-600">
<span className="font-medium text-zinc-800">报销事由:</span>
{description || "未填写"}
</div>
) : <div className="min-w-0 text-sm leading-6 text-zinc-600">报销日期可在草稿阶段修改</div>}
{editable ? (
<Button size="sm" type="button" variant="secondary" onClick={() => setEditing(true)}>
编辑
{showDescription ? "编辑" : "编辑报销日期"}
</Button>
) : null}
</div>
@@ -78,15 +86,17 @@ export function ReimbursementHeaderEditor({
return (
<div className="mt-3 space-y-3 rounded-lg border border-primary-100 bg-primary-50/40 p-3" data-testid="reimbursement-header-editor">
<div className={mobile ? "space-y-3" : "grid gap-3 md:grid-cols-[12rem_1fr]"}>
<div className={mobile || !showDescription ? "space-y-3" : "grid gap-3 md:grid-cols-[12rem_1fr]"}>
<label className="space-y-1 text-sm text-zinc-700">
<span>报销日期</span>
<Input type="date" value={date} disabled={mutation.isPending} onChange={(event) => setDate(event.target.value)} />
</label>
<label className="space-y-1 text-sm text-zinc-700">
<span>报销事由</span>
<Textarea rows={2} maxLength={500} value={reason} disabled={mutation.isPending} onChange={(event) => setReason(event.target.value)} />
</label>
{showDescription ? (
<label className="space-y-1 text-sm text-zinc-700">
<span>报销事由</span>
<Textarea rows={2} maxLength={500} value={reason} disabled={mutation.isPending} onChange={(event) => setReason(event.target.value)} />
</label>
) : null}
</div>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
<div className="flex gap-2">
@@ -45,6 +45,7 @@ import { MobileIcon } from "../components/mobile/MobileIcon";
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
import { ReimbursementHeaderEditor } from "../components/reimbursements/ReimbursementHeaderEditor";
import { isTravelReimbursement } from "../lib/reimbursementTravel";
import { ReimbursementOperationalHints } from "../components/reimbursements/ReimbursementOperationalHints";
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
import { PageScaffold } from "../components/patterns/PageScaffold";
@@ -934,6 +935,7 @@ export function MobileReimbursementDetailPage() {
description={detail.description}
editable={Boolean(canEdit && isParticipant)}
mobile
showDescription={!isTravelReimbursement(detail)}
/>
<ReimbursementTravelSummary
detail={detail}
+114 -5
查看文件
@@ -1,6 +1,6 @@
import { useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { Link, useSearchParams } from "react-router-dom";
import {
cancelPaymentBatch,
createPaymentBatch,
@@ -11,6 +11,7 @@ import {
fetchPaymentBatchDetail,
fetchPaymentBatchReceiptAttachments,
fetchPaymentBatches,
previewPaymentBatchReceiptAttachment,
reviewPaymentBatch,
revealPaymentBatchPayeeBank,
updatePaymentBatchItem,
@@ -173,6 +174,17 @@ export function PaymentBatchesPage() {
>(null);
const [actionReason, setActionReason] = useState("");
const [revealedPayeeAccount, setRevealedPayeeAccount] = useState("");
const [receiptPreview, setReceiptPreview] = useState<{
attachment: PaymentBatchReceiptAttachment;
objectUrl: string;
mimeType: string;
} | null>(null);
useEffect(() => {
return () => {
if (receiptPreview?.objectUrl) URL.revokeObjectURL(receiptPreview.objectUrl);
};
}, [receiptPreview?.objectUrl]);
const listQuery = useQuery({
queryKey: ["payment-batches"],
@@ -421,6 +433,26 @@ export function PaymentBatchesPage() {
downloadReceiptMutation.mutate({ attachment, reason });
}
const previewReceiptMutation = useMutation({
mutationFn: async (attachment: PaymentBatchReceiptAttachment) => {
if (!selectedBatch) throw new Error("请先选择付款批次");
const response = await previewPaymentBatchReceiptAttachment(selectedBatch.id, attachment.id);
return { attachment, response };
},
onSuccess: ({ attachment, response }) => {
setReceiptPreview({
attachment,
objectUrl: URL.createObjectURL(response.blob),
mimeType: response.blob.type || attachment.mimeType,
});
},
onError: (err) => {
const message = resolveError(err);
setError(message);
toast.error(message);
},
});
const voidReceiptMutation = useMutation({
mutationFn: async ({ batchId, attachmentId, reason }: { batchId: string; attachmentId: string; reason: string }) => {
return voidPaymentBatchReceiptAttachment(batchId, attachmentId, reason);
@@ -704,7 +736,9 @@ export function PaymentBatchesPage() {
</div>
<div className="finance-command-panel space-y-4" data-testid="payment-batch-items-panel">
<h3 className="text-base font-semibold text-zinc-900">报销明细</h3>
<h3 className="text-base font-semibold text-zinc-900">
{selectedBatch.entityType === "reimbursement" ? "本批次报销单" : "本批次差旅订单"}
</h3>
<div className="space-y-4">
{selectedBatch.items.map((item) => (
<div key={item.id} className="detail-evidence-card">
@@ -713,9 +747,42 @@ export function PaymentBatchesPage() {
<div className="font-medium text-zinc-900">{item.entityCode}</div>
<div className="text-sm text-zinc-600">{item.entityTitle}</div>
</div>
<div className="text-sm text-zinc-600">{formatMoney(item.amount)} · {paymentItemStatusLabel(item.status)}</div>
<div className="flex flex-wrap items-center gap-3 text-sm text-zinc-600">
<span>{formatMoney(item.amount)} · {paymentItemStatusLabel(item.status)}</span>
{selectedBatch.entityType === "reimbursement" ? (
<Link className="inline-flex min-h-11 items-center font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2" to={`/reimbursements/${item.entityId}`}>
查看报销单
</Link>
) : null}
</div>
</div>
<div className="mt-3 detail-fact-grid text-sm md:grid-cols-4">
{selectedBatch.entityType === "reimbursement" ? (
<div className="mt-3 rounded-lg border border-zinc-200 bg-zinc-50/60 p-3" data-testid="payment-batch-expense-items">
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span className="font-medium text-zinc-900">费用明细 · {item.expenseItems?.length ?? 0} 条</span>
<span className="font-medium text-zinc-900">明细小计 {formatMoney(item.expenseItemsTotal ?? "0.00")}</span>
</div>
{item.expenseItems?.length ? (
<div className="mt-3 space-y-2">
{item.expenseItems.map((expenseItem) => (
<div key={expenseItem.id} className="grid gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm md:grid-cols-[10rem_8rem_minmax(0,1fr)_8rem] md:items-center">
<div><span className="text-xs text-zinc-500 md:hidden">费用类型:</span>{expenseItem.expenseTypeName}</div>
<div><span className="text-xs text-zinc-500 md:hidden">发生日期:</span>{formatDisplayDate(expenseItem.expenseDate)}</div>
<div className="min-w-0 break-words"><span className="text-xs text-zinc-500 md:hidden">说明:</span>{expenseItem.description || "-"}</div>
<div className="font-medium text-zinc-900 md:text-right"><span className="text-xs font-normal text-zinc-500 md:hidden">金额:</span>{formatMoney(expenseItem.amount)}</div>
</div>
))}
</div>
) : (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">未读取到费用明细,请进入原报销单核对后再付款。</div>
)}
{(item.expenseItems?.length ?? 0) > 0 && item.expenseItemsAmountMatches === false ? (
<div className="mt-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">费用明细小计与批次付款金额不一致,请进入原报销单核对。</div>
) : null}
</div>
) : null}
<div className="mt-3 text-sm font-medium text-zinc-900">付款信息</div>
<div className="mt-2 detail-fact-grid text-sm md:grid-cols-4">
<div className="detail-fact-card"><div className="detail-fact-card__label">收款人</div><div className="detail-fact-card__value">{item.payeeName || "-"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">开户银行</div><div className="detail-fact-card__value">{item.bankName || "-"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">银行账户</div><div className="detail-fact-card__value">{item.bankAccountMasked || "-"}</div>{canRevealPayeeBank && ["reviewed", "failed"].includes(selectedBatch.status) && selectedBatch.paymentAccount ? <Button type="button" size="sm" variant="secondary" className="mt-2" onClick={() => { setActionReason(""); setRevealedPayeeAccount(""); setReasonAction({ kind: "payeeBank", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, itemId: item.id, entityCode: item.entityCode }); }}>查看完整账号</Button> : null}</div>
@@ -827,6 +894,17 @@ export function PaymentBatchesPage() {
type="button"
size="sm"
variant="secondary"
className="min-h-11"
disabled={previewReceiptMutation.isPending || Boolean(attachment.archivedAt)}
onClick={() => previewReceiptMutation.mutate(attachment)}
>
{previewReceiptMutation.isPending ? "加载中..." : "在线查看"}
</Button>
<Button
type="button"
size="sm"
variant="secondary"
className="min-h-11"
disabled={downloadReceiptMutation.isPending || Boolean(attachment.archivedAt)}
onClick={() => void handleDownloadReceipt(attachment)}
>
@@ -1040,6 +1118,37 @@ export function PaymentBatchesPage() {
if (confirmPaymentAction.kind === "financePayment") executeMutation.mutate(confirmPaymentAction.batchId);
}}
/>
<Dialog
open={Boolean(receiptPreview)}
onOpenChange={(open) => {
if (!open) setReceiptPreview(null);
}}
>
<DialogContent className="flex max-h-[92vh] max-w-5xl flex-col p-5" data-testid="payment-batch-receipt-preview-dialog">
<DialogHeader>
<DialogTitle>在线查看银行回单</DialogTitle>
<DialogDescription>{receiptPreview?.attachment.originalName ?? "银行回单"} · 本次查看已自动写入审计记录。</DialogDescription>
</DialogHeader>
<div className="mt-4 min-h-0 flex-1 overflow-auto rounded-lg border border-zinc-200 bg-zinc-100 p-2">
{receiptPreview?.mimeType === "application/pdf" ? (
<iframe
className="h-[68vh] min-h-[28rem] w-full rounded bg-white"
src={receiptPreview.objectUrl}
title={`银行回单 ${receiptPreview.attachment.originalName}`}
/>
) : receiptPreview ? (
<img
alt={`银行回单 ${receiptPreview.attachment.originalName}`}
className="mx-auto max-h-[68vh] max-w-full rounded bg-white object-contain"
src={receiptPreview.objectUrl}
/>
) : null}
</div>
<DialogFooter>
<Button type="button" variant="secondary" className="min-h-11" onClick={() => setReceiptPreview(null)}>关闭</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(reasonAction)}
onOpenChange={(open) => {
+2 -1
查看文件
@@ -36,7 +36,7 @@ import {
import { toast } from "sonner";
import { isWecomWebView } from "../lib/runtimeContext";
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
import { hasCompleteTravelSummary } from "../lib/reimbursementTravel";
import { hasCompleteTravelSummary, isTravelReimbursement } from "../lib/reimbursementTravel";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasPermission } from "../auth/permissions";
import { CardSkeleton } from "../components/common/CardSkeleton";
@@ -1137,6 +1137,7 @@ export function ReimbursementDetailPage() {
reimbursementDate={detail.reimbursementDate}
description={detail.description}
editable={Boolean(canEditItems && isReimbursementParticipant)}
showDescription={!isTravelReimbursement(detail)}
/>
<ReimbursementTravelSummary
detail={detail}