实现报销材料合并预览与差旅概要(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -42,6 +42,7 @@ import { useAttachmentDownloadReasonDialog } from "../components/common/useAttac
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { MobileIcon } from "../components/mobile/MobileIcon";
|
||||
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
|
||||
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
@@ -64,6 +65,7 @@ import {
|
||||
} from "../lib/invoiceStatus";
|
||||
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
||||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||
import { hasCompleteTravelSummary } from "../lib/reimbursementTravel";
|
||||
import { formatDisplayDateTime } from "../lib/displayFormat";
|
||||
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
|
||||
import { TEST_IDS } from "../lib/testIds";
|
||||
@@ -71,7 +73,7 @@ import { withUploadTimeout } from "../lib/uploadTimeout";
|
||||
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
|
||||
import { cn } from "../lib/utils";
|
||||
import { isWecomWebView } from "../lib/runtimeContext";
|
||||
import { openFileInBrowser, previewFileInWecom, previewImagesInWecom } from "../lib/wecomFilePreview";
|
||||
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
|
||||
|
||||
type ItemFormState = {
|
||||
mode: "create" | "edit";
|
||||
@@ -743,15 +745,13 @@ export function MobileReimbursementDetailPage() {
|
||||
setMaterialPreviewError("");
|
||||
if (isWecomWebView()) {
|
||||
try {
|
||||
await previewImagesInWecom(bundle.pages.map((page) => page.url));
|
||||
await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, size: bundle.fileSize });
|
||||
return;
|
||||
} catch {
|
||||
toast.error("企业微信滑动预览未打开,已切换到页面预览");
|
||||
toast.error("企业微信材料预览未打开,已切换到浏览器预览");
|
||||
}
|
||||
}
|
||||
navigate(`/mobile/reimbursements/${reimbursementId}/materials`, {
|
||||
state: { bundle, from: `/mobile/reimbursements/${reimbursementId}` },
|
||||
});
|
||||
openFileInBrowser(bundle.signedUrl);
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = resolveErrorMessage(error) || "连续预览生成失败,可逐份打开原件";
|
||||
@@ -777,6 +777,12 @@ export function MobileReimbursementDetailPage() {
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
if (!hasCompleteTravelSummary(detail)) {
|
||||
const message = "请先补全出差事由、地点和起止日期";
|
||||
setActionError(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
if (!detail.invoices.length) {
|
||||
const message = "请先添加至少 1 张发票";
|
||||
setActionError(message);
|
||||
@@ -931,6 +937,13 @@ export function MobileReimbursementDetailPage() {
|
||||
{detail.description ? <p className="mt-3 text-sm leading-6 text-zinc-600">{detail.description}</p> : null}
|
||||
</section>
|
||||
|
||||
<ReimbursementTravelSummary
|
||||
detail={detail}
|
||||
reimbursementId={reimbursementId}
|
||||
editable={Boolean(canEdit && isParticipant)}
|
||||
mobile
|
||||
/>
|
||||
|
||||
<section className="mobile-card" data-testid="mobile-reimbursement-items">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
|
||||
@@ -941,24 +954,6 @@ export function MobileReimbursementDetailPage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{detail.invoices.length || (detail.attachments ?? []).length ? (
|
||||
<Button
|
||||
className="mt-3 min-h-12 w-full"
|
||||
data-testid="mobile-reimbursement-materials-open-all"
|
||||
type="button"
|
||||
disabled={materialPreviewMutation.isPending}
|
||||
onClick={() => void handleOpenAllMaterials()}
|
||||
>
|
||||
{materialPreviewMutation.isPending ? "生成预览中..." : "查看全部材料"}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{materialPreviewError ? (
|
||||
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
{materialPreviewError},可在下方逐份打开原件。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{itemError ? (
|
||||
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{itemError}</div>
|
||||
) : null}
|
||||
@@ -1074,6 +1069,27 @@ export function MobileReimbursementDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detail.invoices.length || (detail.attachments ?? []).length ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p className="text-xs leading-5 text-zinc-500">打开后上下滚动,连续查看全部材料。合并预览仅供核对,原始文件另行保留。</p>
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-reimbursement-materials-open-all"
|
||||
type="button"
|
||||
disabled={materialPreviewMutation.isPending}
|
||||
onClick={() => void handleOpenAllMaterials()}
|
||||
>
|
||||
{materialPreviewMutation.isPending ? "正在准备材料预览…" : "查看材料合并预览"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{materialPreviewError ? (
|
||||
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
{materialPreviewError},可在下方逐份打开原件。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{canOpenInvoiceDialog || canManageAttachments ? (
|
||||
<div className="mobile-two-action-grid mt-3">
|
||||
{canOpenInvoiceDialog ? (
|
||||
|
||||
@@ -35,7 +35,8 @@ import {
|
||||
} from "../api/invoicePool";
|
||||
import { toast } from "sonner";
|
||||
import { isWecomWebView } from "../lib/runtimeContext";
|
||||
import { openFileInBrowser, previewFileInWecom, previewImagesInWecom } from "../lib/wecomFilePreview";
|
||||
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
|
||||
import { hasCompleteTravelSummary } from "../lib/reimbursementTravel";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { CardSkeleton } from "../components/common/CardSkeleton";
|
||||
@@ -45,6 +46,7 @@ import { AttachmentGovernanceBadges } from "../components/common/AttachmentGover
|
||||
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
|
||||
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
|
||||
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
|
||||
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
@@ -736,15 +738,13 @@ export function ReimbursementDetailPage() {
|
||||
setMaterialPreviewError("");
|
||||
if (isWecomWebView()) {
|
||||
try {
|
||||
await previewImagesInWecom(bundle.pages.map((page) => page.url));
|
||||
await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, size: bundle.fileSize });
|
||||
return;
|
||||
} catch {
|
||||
toast.error("企业微信滑动预览未打开,已切换到页面预览");
|
||||
toast.error("企业微信材料预览未打开,已切换到浏览器预览");
|
||||
}
|
||||
}
|
||||
navigate(`/reimbursements/${reimbursementId}/materials`, {
|
||||
state: { bundle, from: `/reimbursements/${reimbursementId}` },
|
||||
});
|
||||
openFileInBrowser(bundle.signedUrl);
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = buildActionErrorMessage(error, "连续预览生成失败,可逐份打开原件");
|
||||
@@ -891,6 +891,12 @@ export function ReimbursementDetailPage() {
|
||||
|
||||
try {
|
||||
setActionError("");
|
||||
if (!hasCompleteTravelSummary(detail)) {
|
||||
const message = "请先补全出差事由、地点和起止日期";
|
||||
setActionError(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
if (detail.invoices.length < 1) {
|
||||
setActionError(missingInvoiceSubmitMessage);
|
||||
toast.error(missingInvoiceSubmitMessage);
|
||||
@@ -1285,6 +1291,12 @@ export function ReimbursementDetailPage() {
|
||||
) : null}
|
||||
</SectionPanel>
|
||||
|
||||
<ReimbursementTravelSummary
|
||||
detail={detail}
|
||||
reimbursementId={reimbursementId}
|
||||
editable={Boolean(canEditItems && isReimbursementParticipant)}
|
||||
/>
|
||||
|
||||
<SectionPanel title="报销明细">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4" data-testid="reimbursement-items">
|
||||
@@ -1436,6 +1448,7 @@ export function ReimbursementDetailPage() {
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<p className="text-sm text-zinc-600">
|
||||
发票 {detail.invoices.length} · 补充附件 {(detail.attachments ?? []).length}
|
||||
<span className="mt-1 block text-xs text-zinc-500">打开后上下滚动,连续查看全部材料。合并预览仅供核对,原始文件另行保留。</span>
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{detail.invoices.length || (detail.attachments ?? []).length ? (
|
||||
@@ -1445,7 +1458,7 @@ export function ReimbursementDetailPage() {
|
||||
disabled={materialPreviewMutation.isPending}
|
||||
onClick={() => void handleOpenAllMaterials()}
|
||||
>
|
||||
{materialPreviewMutation.isPending ? "生成预览中..." : "查看全部材料"}
|
||||
{materialPreviewMutation.isPending ? "正在准备材料预览…" : "查看材料合并预览"}
|
||||
</Button>
|
||||
) : null}
|
||||
{canOpenInvoiceBindDialog ? (
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft, RefreshCw } from "lucide-react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { fetchReimbursementMaterialPreviewBundle, type ReimbursementMaterialPreviewBundle } from "../api/reimbursements";
|
||||
import { CardSkeleton } from "../components/common/CardSkeleton";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
||||
|
||||
type MaterialPageState = {
|
||||
bundle?: ReimbursementMaterialPreviewBundle;
|
||||
from?: string;
|
||||
};
|
||||
|
||||
function pageLabel(page: ReimbursementMaterialPreviewBundle["pages"][number]) {
|
||||
const type = page.materialType === "invoice" ? "发票" : "补充附件";
|
||||
return `${type} ${page.materialIndex}${page.pageNumber > 1 ? ` · 第 ${page.pageNumber} 页` : ""}`;
|
||||
}
|
||||
|
||||
export function ReimbursementMaterialsPage() {
|
||||
const { reimbursementId = "" } = useParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const state = location.state as MaterialPageState | null;
|
||||
const isMobile = location.pathname.startsWith("/mobile/");
|
||||
const fallback = isMobile
|
||||
? `/mobile/reimbursements/${reimbursementId}`
|
||||
: `/reimbursements/${reimbursementId}`;
|
||||
const bundleQuery = useQuery({
|
||||
enabled: Boolean(reimbursementId) && !state?.bundle,
|
||||
queryKey: ["reimbursements", reimbursementId, "material-preview-bundle"],
|
||||
queryFn: () => fetchReimbursementMaterialPreviewBundle(reimbursementId),
|
||||
staleTime: 0,
|
||||
retry: false,
|
||||
});
|
||||
const bundle = state?.bundle ?? bundleQuery.data;
|
||||
|
||||
const handleBack = () => {
|
||||
const from = typeof state?.from === "string" && state.from.startsWith("/")
|
||||
? state.from
|
||||
: fallback;
|
||||
navigate(from, { replace: true });
|
||||
};
|
||||
|
||||
if (!reimbursementId) {
|
||||
return <div className="card-std p-6 text-sm text-red-600">缺少报销单参数。</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={isMobile ? "space-y-3 pb-6" : "mx-auto max-w-5xl space-y-4 pb-8"} data-testid="reimbursement-material-viewer">
|
||||
<header className="sticky top-0 z-20 flex min-h-14 items-center gap-3 border-b border-zinc-200 bg-white/95 px-3 py-2 backdrop-blur md:rounded-xl md:border">
|
||||
<Button aria-label="返回报销单" className="h-11 w-11 shrink-0 p-0" type="button" variant="secondary" onClick={handleBack}>
|
||||
<ArrowLeft aria-hidden="true" className="size-5" />
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="truncate text-base font-semibold text-zinc-900">全部报销材料</h1>
|
||||
<p className="text-xs text-zinc-500">按发票、补充附件和文件页序排列</p>
|
||||
</div>
|
||||
{bundle ? <span className="shrink-0 text-sm text-zinc-600">共 {bundle.pages.length} 页</span> : null}
|
||||
</header>
|
||||
|
||||
{bundleQuery.isLoading && !bundle ? <CardSkeleton lines={8} /> : null}
|
||||
{bundleQuery.isError && !bundle ? (
|
||||
<div className="card-std space-y-3 p-5">
|
||||
<p className="text-sm text-red-600">{resolveErrorMessage(bundleQuery.error)}</p>
|
||||
<Button type="button" variant="secondary" disabled={bundleQuery.isFetching} onClick={() => void bundleQuery.refetch()}>
|
||||
<RefreshCw aria-hidden="true" className="mr-2 size-4" />
|
||||
{bundleQuery.isFetching ? "重新生成中..." : "重新生成预览"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{bundle ? (
|
||||
<div className="space-y-4 px-3 md:px-0">
|
||||
{bundle.pages.map((page, index) => (
|
||||
<figure key={`${page.materialType}-${page.materialIndex}-${page.pageNumber}-${index}`} className="overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-sm">
|
||||
<figcaption className="border-b border-zinc-100 px-4 py-3 text-sm font-medium text-zinc-700">
|
||||
{pageLabel(page)}
|
||||
</figcaption>
|
||||
<div className="flex min-h-40 items-center justify-center bg-zinc-100 p-2 md:p-4">
|
||||
<img
|
||||
alt={`${pageLabel(page)}预览`}
|
||||
className="h-auto max-w-full rounded-md bg-white"
|
||||
decoding="async"
|
||||
loading="eager"
|
||||
src={page.url}
|
||||
/>
|
||||
</div>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReimbursementMaterialsPage;
|
||||
在新工单中引用
屏蔽一个用户