实现 B-2b 统一报销工作台迁移(reimbursement-form-shared-component-migration)
这个提交包含在:
@@ -2,10 +2,9 @@ import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveEr
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, ChevronRight } from "lucide-react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { fetchExpenseTypes } from "../api/expenseTypes";
|
||||
import type { ExpenseStandardCheck, PrecheckResponse } from "../api/feeControl";
|
||||
import { fetchProjects } from "../api/projects";
|
||||
import {
|
||||
createReimbursementItem,
|
||||
deleteReimbursementAttachment,
|
||||
@@ -21,7 +20,6 @@ import {
|
||||
updateReimbursementItem,
|
||||
uploadReimbursementAttachments,
|
||||
withdrawReimbursement,
|
||||
linkReimbursementProject,
|
||||
linkReimbursementInvoiceItem,
|
||||
type ReimbursementItem,
|
||||
} from "../api/reimbursements";
|
||||
@@ -38,17 +36,16 @@ import { isWecomWebView } from "../lib/runtimeContext";
|
||||
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
|
||||
import { hasCompleteTravelSummary, isTravelReimbursement } from "../lib/reimbursementTravel";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission, hasPermission } from "../auth/permissions";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { CardSkeleton } from "../components/common/CardSkeleton";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { useUnsavedChangesGuard } from "../components/common/UnsavedChangesGuard";
|
||||
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
|
||||
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 { ReimbursementHeaderEditor } from "../components/reimbursements/ReimbursementHeaderEditor";
|
||||
import { ReimbursementOperationalHints } from "../components/reimbursements/ReimbursementOperationalHints";
|
||||
import { ReimbursementDesktopWorkspaceShell } from "../components/reimbursements/ReimbursementDesktopWorkspaceShell";
|
||||
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { Button } from "../components/ui/button";
|
||||
@@ -60,21 +57,12 @@ import {
|
||||
DialogTitle,
|
||||
} from "../components/ui/dialog";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
|
||||
import {
|
||||
BusinessDetailActionPanel,
|
||||
BusinessDetailAside,
|
||||
BusinessDetailMain,
|
||||
BusinessDetailWorkspace,
|
||||
} from "../components/patterns/BusinessDetailTemplate";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { BusinessDetailActionPanel } from "../components/patterns/BusinessDetailTemplate";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import {
|
||||
formatInvoiceDisplayTitle,
|
||||
resolveInvoicePartyText,
|
||||
resolveInvoiceNextStepText,
|
||||
resolveInvoiceStatusLabel,
|
||||
resolveInvoiceUserMessage,
|
||||
} from "../lib/invoiceStatus";
|
||||
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
|
||||
@@ -94,12 +82,11 @@ import {
|
||||
type ItemEditorInitialValues,
|
||||
type ItemEditorSaveData,
|
||||
} from "./reimbursement-detail/ReimbursementItemEditor";
|
||||
import { shouldShowOfficeElectronicsHint } from "../lib/reimbursementPolicyHints";
|
||||
import { buildPrecheckDialogMessage } from "../lib/precheck";
|
||||
import { buildReimbursementApprovalEvidence } from "../lib/reimbursementApprovalProjection";
|
||||
import { buildReimbursementWorkspaceHeader, buildReimbursementWorkspaceIssueAnchors } from "../lib/reimbursementWorkspace";
|
||||
import {
|
||||
focusFirstInvoiceGuardIssue,
|
||||
invoiceGuardInvoiceMessages,
|
||||
invoiceGuardItemMessages,
|
||||
parseReimbursementInvoiceGuardIssues,
|
||||
resolveInvoiceGuardIssuesAfterLink,
|
||||
type ReimbursementInvoiceGuardIssues,
|
||||
@@ -111,7 +98,6 @@ type SelectedInvoiceMap = Record<string, string>;
|
||||
function buildActionErrorMessage(error: unknown, fallbackMessage: string) {
|
||||
return resolveUserErrorMessage(error, fallbackMessage);
|
||||
}
|
||||
|
||||
export function ReimbursementDetailPage() {
|
||||
const { reimbursementId = "" } = useParams();
|
||||
const location = useLocation();
|
||||
@@ -121,7 +107,6 @@ export function ReimbursementDetailPage() {
|
||||
|
||||
const [actionError, setActionError] = useState("");
|
||||
const [invoiceGuardIssues, setInvoiceGuardIssues] = useState<ReimbursementInvoiceGuardIssues | null>(null);
|
||||
const [invoiceItemSelections, setInvoiceItemSelections] = useState<Record<string, string>>({});
|
||||
const [invoiceItemLinkErrors, setInvoiceItemLinkErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const [itemEditor, setItemEditor] = useState<ItemEditorState>(null);
|
||||
@@ -139,7 +124,6 @@ export function ReimbursementDetailPage() {
|
||||
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
|
||||
const [deleteAttachmentTarget, setDeleteAttachmentTarget] = useState<{ id: string; originalName: string } | null>(null);
|
||||
const [invoiceActionError, setInvoiceActionError] = useState("");
|
||||
const [materialPreviewError, setMaterialPreviewError] = useState("");
|
||||
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
|
||||
const [pendingOcrLinkedItemIds, setPendingOcrLinkedItemIds] = useState<Record<string, string>>({});
|
||||
const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 });
|
||||
@@ -172,22 +156,10 @@ export function ReimbursementDetailPage() {
|
||||
queryFn: () => fetchExpenseTypes({ enabled: true }),
|
||||
});
|
||||
|
||||
const projectsQuery = useQuery({
|
||||
enabled: Boolean(
|
||||
detailQuery.data &&
|
||||
me?.id &&
|
||||
[detailQuery.data.ownerId, detailQuery.data.submitterId].includes(me.id) &&
|
||||
["draft", "rejected"].includes(detailQuery.data.status),
|
||||
),
|
||||
queryKey: ["projects", "reimbursement-link"],
|
||||
queryFn: () => fetchProjects({ mine: true }),
|
||||
});
|
||||
|
||||
const canSubmitExpense = hasPermission(permissions, "expense.submit");
|
||||
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
|
||||
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
|
||||
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
|
||||
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
|
||||
|
||||
const reimbursementPrecheckQuery = useQuery({
|
||||
enabled: Boolean(
|
||||
@@ -385,23 +357,6 @@ export function ReimbursementDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const projectLinkMutation = useMutation({
|
||||
mutationFn: (projectId: string | null) => linkReimbursementProject(reimbursementId, projectId),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["projects"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
]);
|
||||
toast.success("已更新关联项目");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = buildActionErrorMessage(error, "关联项目失败");
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const bindInvoicesMutation = useMutation({
|
||||
mutationFn: async (invoiceSelections: Array<{ invoiceId: string; linkedItemId?: string }>) => {
|
||||
if (!reimbursementId) {
|
||||
@@ -544,11 +499,6 @@ export function ReimbursementDetailPage() {
|
||||
variables.invoiceId,
|
||||
variables.linkedItemId,
|
||||
));
|
||||
setInvoiceItemSelections((previous) => {
|
||||
const next = { ...previous };
|
||||
delete next[variables.invoiceId];
|
||||
return next;
|
||||
});
|
||||
setInvoiceItemLinkErrors((previous) => {
|
||||
const next = { ...previous };
|
||||
delete next[variables.invoiceId];
|
||||
@@ -762,7 +712,6 @@ export function ReimbursementDetailPage() {
|
||||
const materialPreviewMutation = useMutation({
|
||||
mutationFn: (reason?: string) => fetchReimbursementMaterialPreviewBundle(reimbursementId, reason),
|
||||
onSuccess: async (bundle) => {
|
||||
setMaterialPreviewError("");
|
||||
if (isWecomWebView()) {
|
||||
try {
|
||||
await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, size: bundle.fileSize });
|
||||
@@ -775,8 +724,7 @@ export function ReimbursementDetailPage() {
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = buildActionErrorMessage(error, "连续预览生成失败,可逐份打开原件");
|
||||
setMaterialPreviewError(message);
|
||||
toast.error("连续预览生成失败,可逐份打开原件");
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -837,10 +785,8 @@ export function ReimbursementDetailPage() {
|
||||
isReimbursementParticipant;
|
||||
const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? [];
|
||||
const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block");
|
||||
const warnStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "warn");
|
||||
const missingInvoiceSubmitMessage = "提交前需先添加至少 1 张发票。";
|
||||
const requiresAnyInvoice = detail.items.some((item) => item.expenseType?.requiresInvoice !== false);
|
||||
const submitInvoiceNotice = canMaintainReimbursement && requiresAnyInvoice && detail.invoices.length < 1 ? missingInvoiceSubmitMessage : "";
|
||||
const selectedInvoiceEntries = Object.entries(selectedInvoices);
|
||||
|
||||
function handleInvoicePdfSelection(files: FileList | null) {
|
||||
@@ -981,19 +927,6 @@ export function ReimbursementDetailPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const resolveLinkedItemLabel = (linkedItemId: string | null) => {
|
||||
if (!linkedItemId) {
|
||||
return "未关联";
|
||||
}
|
||||
|
||||
const linkedItem = detail.items.find((item) => item.id === linkedItemId);
|
||||
if (!linkedItem) {
|
||||
return "无法识别";
|
||||
}
|
||||
|
||||
return `${linkedItem.expenseType?.name ?? "未识别费用类型"} · ${linkedItem.description ?? "未填写说明"}`;
|
||||
};
|
||||
|
||||
const itemCount = detail.items.length;
|
||||
const invoiceCount = detail.invoices.length;
|
||||
const hasBlockingPrecheck = blockStandardChecks.length > 0;
|
||||
@@ -1001,9 +934,6 @@ export function ReimbursementDetailPage() {
|
||||
const currentProcessingStage = processingProgress.stages.find((stage) =>
|
||||
stage.state === "current" || stage.state === "exception",
|
||||
);
|
||||
const currentHandlerText = currentProcessingStage
|
||||
? `${currentProcessingStage.label}${currentProcessingStage.actorName ? ` · ${currentProcessingStage.actorName}` : ""}`
|
||||
: detail.status === "paid" ? "已完成" : "待处理";
|
||||
const nextStepText = (() => {
|
||||
if (["draft", "rejected"].includes(detail.status)) {
|
||||
if (!isReimbursementParticipant) {
|
||||
@@ -1031,7 +961,6 @@ export function ReimbursementDetailPage() {
|
||||
}
|
||||
return "查看记录";
|
||||
})();
|
||||
const showReviewHints = Boolean(budgetHint || detail.riskFlags.length);
|
||||
const primaryAction = (() => {
|
||||
if (canMaintainReimbursement) {
|
||||
if (!itemCount) {
|
||||
@@ -1105,636 +1034,74 @@ export function ReimbursementDetailPage() {
|
||||
) : null,
|
||||
];
|
||||
|
||||
const workspaceEvidence = buildReimbursementApprovalEvidence(detail, {
|
||||
standardViolations: precheckStandardChecks.filter((item) => item.exceeded),
|
||||
budgetCheck: null,
|
||||
budgetCheckStatus: "not_applicable",
|
||||
parseStatus: "ready",
|
||||
});
|
||||
const workspaceModel = {
|
||||
header: buildReimbursementWorkspaceHeader(detail),
|
||||
evidence: workspaceEvidence,
|
||||
issueAnchors: buildReimbursementWorkspaceIssueAnchors(workspaceEvidence),
|
||||
};
|
||||
const workspaceCapabilities = {
|
||||
onOpenInvoice: (invoice: { id: string }) => openInvoiceMutation.mutate(invoice.id),
|
||||
onOpenAttachment: (attachment: { id: string }) => {
|
||||
const sourceAttachment = detail.attachments.find((item) => item.id === attachment.id);
|
||||
if (sourceAttachment) void handleOpenAttachment(sourceAttachment);
|
||||
},
|
||||
onOpenMaterialBundle: detail.invoices.length || detail.attachments.length ? () => void handleOpenAllMaterials() : undefined,
|
||||
onEditItem: canMaintainReimbursement ? (item: { id: string }) => {
|
||||
const sourceItem = detail.items.find((candidate) => candidate.id === item.id);
|
||||
if (sourceItem) startEditItem(sourceItem);
|
||||
} : undefined,
|
||||
onDeleteItem: canMaintainReimbursement ? (item: { id: string }) => setConfirmDeleteItemId(item.id) : undefined,
|
||||
onLinkInvoice: canMaintainReimbursement ? (invoice: { id: string }, linkedItemId: string) => linkInvoiceItemMutation.mutate({ invoiceId: invoice.id, linkedItemId }) : undefined,
|
||||
linkInvoicePending: linkInvoiceItemMutation.isPending,
|
||||
linkInvoiceErrors: invoiceItemLinkErrors,
|
||||
onUnlinkInvoice: canUnbindInvoices ? (invoice: { id: string }) => unbindInvoiceMutation.mutate(invoice.id) : undefined,
|
||||
onDeleteAttachment: canMaintainReimbursement ? (attachment: { id: string; originalName: string }) =>
|
||||
setDeleteAttachmentTarget({ id: attachment.id, originalName: attachment.originalName }) : undefined,
|
||||
};
|
||||
const workspaceHeaderDetails = canMaintainReimbursement || isTravelReimbursement(detail) || detail.project || detail.linkedTravelRequest ? (
|
||||
<SectionPanel title="补充信息">
|
||||
{canMaintainReimbursement ? (
|
||||
<ReimbursementHeaderEditor reimbursementId={reimbursementId} reimbursementDate={detail.reimbursementDate} description={detail.description} editable showDescription={!isTravelReimbursement(detail)} />
|
||||
) : null}
|
||||
<ReimbursementTravelSummary detail={detail} reimbursementId={reimbursementId} editable={canMaintainReimbursement} embedded />
|
||||
{detail.project ? <p className="mt-3 text-sm text-zinc-600">关联项目:{detail.project.projectNo} · {detail.project.name}</p> : null}
|
||||
{detail.linkedTravelRequest ? <Link className="mt-2 inline-flex min-h-11 items-center text-primary hover:underline" to={"/travel-requests?tab=requests&id=" + detail.linkedTravelRequest.id}>差旅申请:{detail.linkedTravelRequest.requestNo ?? "未编号"}</Link> : null}
|
||||
</SectionPanel>
|
||||
) : null;
|
||||
const workspaceBeforeEvidence = (
|
||||
<>
|
||||
{invoiceGuardIssues ? <div className="rounded-lg border border-red-300 bg-red-50 px-4 py-3" role="alert" aria-live="assertive"><p className="font-medium text-red-900">票据检查未通过</p><p className="mt-1 text-sm text-red-700">请按明细和票据中的提示处理后再次提交。</p></div> : null}
|
||||
{actionError ? <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" data-testid="reimbursement-action-error" role="alert">{actionError}</p> : null}
|
||||
{itemError ? <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">{itemError}</p> : null}
|
||||
{canMaintainReimbursement ? (
|
||||
<SectionPanel title="维护报销内容" actions={<div className="flex flex-wrap gap-2"><Button data-testid="reimbursement-item-add" disabled={itemSaveMutation.isPending} onClick={startCreateItem} type="button" variant="secondary">新增明细</Button><Button data-testid={TEST_IDS.reimbursement.invoiceAdd} disabled={!canOpenInvoiceBindDialog} onClick={() => handleInvoiceDialogOpenChange(true)} type="button" variant="secondary">添加发票</Button><label className="inline-flex min-h-11 cursor-pointer items-center rounded-md border border-zinc-300 bg-white px-4 text-sm font-medium text-zinc-700" htmlFor="reimbursement-attachment-upload">选择附件</label><input className="hidden" id="reimbursement-attachment-upload" type="file" accept="application/pdf,image/jpeg,image/png,image/webp" multiple onChange={(event) => { const files = Array.from(event.target.files ?? []); if (files.length > MAX_REIMBURSEMENT_ATTACHMENT_FILES) { setSelectedAttachmentFiles([]); event.target.value = ""; toast.error(`单次最多上传 ${MAX_REIMBURSEMENT_ATTACHMENT_FILES} 个附件,请分批上传。`); return; } setSelectedAttachmentFiles(files); }} /><Button disabled={!selectedAttachmentFiles.length || uploadAttachmentsMutation.isPending} onClick={() => uploadAttachmentsMutation.mutate(selectedAttachmentFiles)} type="button" variant="secondary">{uploadAttachmentsMutation.isPending ? "上传中..." : selectedAttachmentFiles.length ? `上传 ${selectedAttachmentFiles.length} 个附件` : "上传附件"}</Button></div>}>
|
||||
{itemEditor ? <ReimbursementItemEditor mode={itemEditor.mode} expenseTypes={expenseTypes} expenseTypesLoading={expenseTypesQuery.isLoading} expenseTypesError={expenseTypesQuery.isError} departmentId={detail.departmentId} initialValues={editorInitialValues} onSave={handleItemSave} onCancel={handleItemCancel} isPending={itemSaveMutation.isPending} /> : <p className="text-sm text-zinc-500">选择上方动作维护明细、发票或补充附件。</p>}
|
||||
</SectionPanel>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
const workspaceAfterEvidence = (
|
||||
<>
|
||||
{(budgetHint?.exceedsThreshold || detail.riskFlags.length) ? <SectionPanel title="需要关注">{budgetHint?.exceedsThreshold ? <p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">预算 {budgetHint.budgetCode}:提交后预计使用 {budgetHint.nextUsagePercent.toFixed(2)}%,已达到预警阈值。</p> : null}{detail.riskFlags.length ? <RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> : null}</SectionPanel> : null}
|
||||
<SectionPanel title="处理进度"><ProcessingProgress progress={processingProgress} ariaLabel="报销处理进度" testId="reimbursement-workflow-progress" targetLinkLabel="查看所在付款批次" /></SectionPanel>
|
||||
</>
|
||||
);
|
||||
const workspaceActionPanel = (
|
||||
<BusinessDetailActionPanel status={<StatusBadge status={detail.status} />} primaryAction={primaryAction} secondaryActions={secondaryActions} moreActions={showSecondaryDetail ? <div className="grid gap-2" data-testid="reimbursement-secondary-detail">{detail.status !== "paid" ? <Button asChild variant="secondary"><Link to={"/reimbursements/" + detail.id + "/print"}>打印 / PDF</Link></Button> : null}{canDeleteReimbursement ? <Button data-testid="reimbursement-delete-draft" disabled={deleteReimbursementMutation.isPending} onClick={() => setConfirmDeleteReimbursement(true)} type="button" variant="destructive">删除草稿</Button> : null}</div> : null} summary={<><div>下一步:{nextStepText}</div>{workspaceModel.issueAnchors.map((issue) => <a className="block hover:underline" href={"#" + issue.id} key={issue.label}>{issue.label}</a>)}</>} testId="reimbursement-action-panel">
|
||||
<ReimbursementActions status={detail.status} showSubmitAction={false} showWithdrawAction={isReimbursementParticipant} actionError={["reviewing", "approved"].includes(detail.status) ? actionError : ""} submitDisabled={blockStandardChecks.length > 0} submitHint="" submitNotice="" isPending={mutation.isPending} onSubmit={() => void handleSubmitAction()} onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })} />
|
||||
</BusinessDetailActionPanel>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageScaffold>
|
||||
<>
|
||||
{downloadReasonDialog}
|
||||
<div>
|
||||
<Button asChild className="min-h-11 px-2" data-testid="reimbursement-back" variant="ghost">
|
||||
<Link replace to={backSource}>
|
||||
<ArrowLeft aria-hidden="true" className="size-4" />
|
||||
{backLabel}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<EntityOverviewHero
|
||||
title={`报销详情 ${displayCode}`}
|
||||
meta={(
|
||||
<>
|
||||
<StatusBadge status={detail.status} />
|
||||
{!isReimbursementParticipant ? (
|
||||
<span className="rounded-full bg-zinc-100 px-2.5 py-1 text-xs font-medium text-zinc-600">只读查看</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<BusinessDetailWorkspace className="mx-auto w-full max-w-7xl">
|
||||
<BusinessDetailMain>
|
||||
<SectionPanel title="报销概况">
|
||||
<div className="detail-command-panel space-y-4" data-testid="reimbursement-overview">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-zinc-500">下一步</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{nextStepText}</div>
|
||||
</div>
|
||||
|
||||
<div data-testid="reimbursement-business-summary">
|
||||
<div className="detail-fact-grid xl:grid-cols-3" data-testid="reimbursement-summary">
|
||||
<div className="detail-fact-card">
|
||||
<div className="detail-fact-card__label">报销总额</div>
|
||||
<div className="detail-fact-card__value">{formatMoney(detail.totalAmount)}</div>
|
||||
</div>
|
||||
<div className="detail-fact-card">
|
||||
<div className="detail-fact-card__label">当前处理</div>
|
||||
<div className="detail-fact-card__value">{currentHandlerText}</div>
|
||||
</div>
|
||||
<div className="detail-fact-card">
|
||||
<div className="detail-fact-card__label">部门</div>
|
||||
<div className="detail-fact-card__value">
|
||||
{detail.department?.name ?? "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-fact-card">
|
||||
<div className="detail-fact-card__label">报销日期</div>
|
||||
<div className="detail-fact-card__value">{detail.reimbursementDate ?? "-"}</div>
|
||||
</div>
|
||||
<div className="detail-fact-card">
|
||||
<div className="detail-fact-card__label">提交人 / 报销人</div>
|
||||
<div className="detail-fact-card__value">{detail.submitter?.name ?? "-"} / {detail.owner?.name ?? "-"}</div>
|
||||
</div>
|
||||
<div className="detail-fact-card">
|
||||
<div className="detail-fact-card__label">关联项目</div>
|
||||
<div className="detail-fact-card__value">
|
||||
{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ReimbursementHeaderEditor
|
||||
reimbursementId={reimbursementId}
|
||||
reimbursementDate={detail.reimbursementDate}
|
||||
description={detail.description}
|
||||
editable={Boolean(canEditItems && isReimbursementParticipant)}
|
||||
showDescription={!isTravelReimbursement(detail)}
|
||||
/>
|
||||
<ReimbursementTravelSummary
|
||||
detail={detail}
|
||||
reimbursementId={reimbursementId}
|
||||
editable={Boolean(canEditItems && isReimbursementParticipant)}
|
||||
embedded
|
||||
/>
|
||||
{canMaintainReimbursement || detail.project || detail.linkedTravelRequest ? (
|
||||
<div className="mt-4 border-t border-zinc-200 pt-4 text-sm" data-testid="reimbursement-related-records">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<span className="font-medium text-zinc-900">关联事项</span>
|
||||
{detail.linkedTravelRequest ? (
|
||||
<Link to={`/travel-requests?tab=requests&id=${detail.linkedTravelRequest.id}`} className="text-primary hover:underline">
|
||||
差旅申请 {detail.linkedTravelRequest.requestNo ?? "未编号"}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
{canMaintainReimbursement ? (
|
||||
<select
|
||||
className="select-std mt-3 max-w-xl"
|
||||
value={detail.projectId ?? ""}
|
||||
onChange={(event) => projectLinkMutation.mutate(event.target.value || null)}
|
||||
disabled={projectLinkMutation.isPending}
|
||||
aria-label="关联项目"
|
||||
>
|
||||
<option value="">不关联项目</option>
|
||||
{(projectsQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>{item.projectNo} · {item.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : detail.project ? (
|
||||
<Link className="mt-2 inline-block text-primary hover:underline" to={`/projects/${detail.project.id}`}>
|
||||
项目 {detail.project.projectNo} · {detail.project.name}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{actionError ? (
|
||||
<p
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
|
||||
data-testid="reimbursement-action-error"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
{actionError}
|
||||
</p>
|
||||
) : null}
|
||||
{submitInvoiceNotice ? (
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
|
||||
{submitInvoiceNotice}
|
||||
</p>
|
||||
) : null}
|
||||
{hasBlockingPrecheck ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
请修改超标明细后提交。
|
||||
</p>
|
||||
) : null}
|
||||
{showReviewHints ? (
|
||||
<div className="space-y-3" data-testid="reimbursement-review-hints">
|
||||
{budgetHint ? (
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
预算 {budgetHint.budgetCode}:提交后预计使用 {budgetHint.nextUsagePercent.toFixed(2)}%
|
||||
{budgetHint.exceedsThreshold ? ",已达到预警阈值。" : "。"}
|
||||
</p>
|
||||
) : null}
|
||||
{detail.riskFlags.length ? <RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="处理进度">
|
||||
<ProcessingProgress
|
||||
progress={processingProgress}
|
||||
ariaLabel="报销处理进度"
|
||||
testId="reimbursement-workflow-progress"
|
||||
targetLinkLabel="查看所在付款批次"
|
||||
/>
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="报销明细">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4" data-testid="reimbursement-items">
|
||||
{canMaintainReimbursement ? (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
data-testid="reimbursement-item-add"
|
||||
disabled={itemSaveMutation.isPending || itemDeleteMutation.isPending}
|
||||
onClick={startCreateItem}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
新增明细
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{itemError ? (
|
||||
<p
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
|
||||
data-testid="reimbursement-item-error"
|
||||
>
|
||||
{itemError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{reimbursementPrecheckQuery.isLoading && canMaintainReimbursement ? (
|
||||
<p className="text-sm text-zinc-500">正在刷新费用标准校验结果...</p>
|
||||
) : null}
|
||||
|
||||
<div className="ops-table-focus overflow-x-auto border border-zinc-200">
|
||||
<Table data-testid="reimbursement-items-table">
|
||||
<TableHeader>
|
||||
<tr>
|
||||
<TableHead>费用类型</TableHead>
|
||||
<TableHead>发生日期</TableHead>
|
||||
<TableHead>金额</TableHead>
|
||||
<TableHead>说明</TableHead>
|
||||
<TableHead className="w-44">操作</TableHead>
|
||||
</tr>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detail.items.length ? (
|
||||
detail.items.map((item) => (
|
||||
<TableRow
|
||||
data-testid="reimbursement-item-row"
|
||||
data-reimbursement-guard-issue={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? "true" : undefined}
|
||||
key={item.id}
|
||||
tabIndex={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? -1 : undefined}
|
||||
className={
|
||||
invoiceGuardItemMessages(invoiceGuardIssues, item.id).length
|
||||
? "bg-red-50/70"
|
||||
: blockStandardChecks.some((check) => check.itemId === item.id)
|
||||
? "bg-red-50/70"
|
||||
: warnStandardChecks.some((check) => check.itemId === item.id)
|
||||
? "bg-amber-50/70"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">
|
||||
{item.expenseType?.name ?? "未识别费用类型"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.expenseDate ?? "-"}</TableCell>
|
||||
<TableCell>
|
||||
<div>{formatMoney(item.amount)}</div>
|
||||
{precheckStandardChecks
|
||||
.filter((check) => check.itemId === item.id)
|
||||
.map((check) => (
|
||||
<div
|
||||
key={check.standardId}
|
||||
className={`mt-1 text-xs ${check.exceeded ? (check.controlMode === "block" ? "text-red-600" : "text-amber-600") : "text-zinc-500"}`}
|
||||
>
|
||||
标准 {formatMoney(check.maxAmount)}/{check.perUnit}
|
||||
{check.exceeded ? `,超出 ${formatMoney(check.exceededAmount)}` : ""}
|
||||
</div>
|
||||
))}
|
||||
{invoiceGuardItemMessages(invoiceGuardIssues, item.id).map((message) => (
|
||||
<div
|
||||
key={message}
|
||||
className="mt-2 rounded-md border border-red-200 bg-red-50 px-2 py-1.5 text-xs leading-5 text-red-700"
|
||||
role="alert"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>{item.description ?? "-"}</div>
|
||||
{shouldShowOfficeElectronicsHint(item.expenseType, item.amount) ? (
|
||||
<div className="mt-1 text-xs text-amber-600">
|
||||
该金额通常应选择办公电子设备。
|
||||
</div>
|
||||
) : null}
|
||||
{precheckStandardChecks
|
||||
.filter((check) => check.itemId === item.id && check.exceeded)
|
||||
.map((check) => (
|
||||
<div
|
||||
key={`${check.standardId}-warning`}
|
||||
className={`mt-1 text-xs ${check.controlMode === "block" ? "text-red-600" : "text-amber-600"}`}
|
||||
>
|
||||
{check.standardName}:当前金额超出费用标准。
|
||||
</div>
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{canMaintainReimbursement ? (
|
||||
<div className="flex min-w-36 items-center gap-2">
|
||||
<Button
|
||||
data-testid="reimbursement-item-edit"
|
||||
className="min-w-16"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => startEditItem(item)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="reimbursement-item-delete"
|
||||
className="min-w-16"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={itemDeleteMutation.isPending}
|
||||
onClick={() => setConfirmDeleteItemId(item.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell className="py-3 text-zinc-500" colSpan={5}>
|
||||
暂无明细
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
<tfoot className="border-t border-zinc-200 bg-zinc-50 font-medium text-zinc-900">
|
||||
<tr>
|
||||
<TableCell colSpan={2}>共 {detail.items.length} 条明细</TableCell>
|
||||
<TableCell>{formatMoney(detail.totalAmount)}</TableCell>
|
||||
<TableCell colSpan={2}>报销合计</TableCell>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</Table>
|
||||
</div>
|
||||
<ReimbursementOperationalHints
|
||||
precheck={reimbursementPrecheckQuery.data}
|
||||
items={detail.items}
|
||||
canAccessAssets={canAccessAssets}
|
||||
/>
|
||||
|
||||
{itemEditor ? (
|
||||
<ReimbursementItemEditor
|
||||
mode={itemEditor.mode}
|
||||
expenseTypes={expenseTypes}
|
||||
expenseTypesLoading={expenseTypesQuery.isLoading}
|
||||
expenseTypesError={expenseTypesQuery.isError}
|
||||
departmentId={detail.departmentId}
|
||||
initialValues={editorInitialValues}
|
||||
onSave={handleItemSave}
|
||||
onCancel={handleItemCancel}
|
||||
isPending={itemSaveMutation.isPending}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="报销材料">
|
||||
<div className="space-y-4 text-sm" data-testid="reimbursement-materials">
|
||||
<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 ? (
|
||||
<Button
|
||||
data-testid="reimbursement-materials-open-all"
|
||||
type="button"
|
||||
disabled={materialPreviewMutation.isPending}
|
||||
onClick={() => void handleOpenAllMaterials()}
|
||||
>
|
||||
{materialPreviewMutation.isPending ? "正在准备材料预览…" : "查看材料合并预览"}
|
||||
</Button>
|
||||
) : null}
|
||||
{canOpenInvoiceBindDialog ? (
|
||||
<Button
|
||||
data-testid={TEST_IDS.reimbursement.invoiceAdd}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => handleInvoiceDialogOpenChange(true)}
|
||||
>
|
||||
添加发票
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{materialPreviewError ? (
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
{materialPreviewError},可在下方逐份打开原件。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{invoiceActionError ? (
|
||||
<p
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
|
||||
data-testid={TEST_IDS.reimbursement.invoiceActionError}
|
||||
>
|
||||
{invoiceActionError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{detail.invoiceAmountManualReviewRequired ? (
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-800">
|
||||
本单包含票面金额未自动读取的发票,系统未自动比较相关明细金额;财务审批时需查看发票原件核对。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2" data-testid={TEST_IDS.reimbursement.invoices}>
|
||||
{detail.invoices.map((invoice) => {
|
||||
const invoiceHint = invoice.status === "bound" && !canMaintainReimbursement
|
||||
? "已关联"
|
||||
: resolveInvoiceNextStepText(invoice);
|
||||
const guardMessages = invoiceGuardInvoiceMessages(
|
||||
invoiceGuardIssues,
|
||||
invoice.id,
|
||||
invoice.linkedItemId,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={`detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center ${guardMessages.length ? "border-red-300 bg-red-50/60" : ""}`}
|
||||
data-reimbursement-guard-issue={guardMessages.length ? "true" : undefined}
|
||||
key={invoice.id}
|
||||
tabIndex={guardMessages.length ? -1 : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-11 min-w-0 flex-1 items-center gap-3 rounded-md text-left transition-colors hover:bg-white active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
|
||||
disabled={openInvoiceMutation.isPending}
|
||||
aria-label={`查看发票原件:${formatInvoiceDisplayTitle(invoice)}`}
|
||||
onClick={() => openInvoiceMutation.mutate(invoice.id)}
|
||||
>
|
||||
<span className="inline-flex shrink-0 rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700">发票</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium text-zinc-900">{formatInvoiceDisplayTitle(invoice)}</span>
|
||||
<span className="mt-1 block text-xs text-zinc-500">
|
||||
{[resolveInvoicePartyText(invoice), invoice.invoiceDate ? formatDisplayDate(invoice.invoiceDate) : "", formatMoney(invoice.totalAmount)].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-zinc-500">明细:{resolveLinkedItemLabel(invoice.linkedItemId)}</span>
|
||||
{invoiceHint ? <span className="mt-1 block text-xs text-zinc-500">{invoiceHint}</span> : null}
|
||||
</span>
|
||||
<span className="badge-neutral shrink-0">{resolveInvoiceStatusLabel(invoice.status)}</span>
|
||||
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" />
|
||||
</button>
|
||||
{(guardMessages.length
|
||||
|| invoice.amountEvidenceStatus === "manual_review"
|
||||
|| invoice.amountEvidenceStatus === "ocr_failed"
|
||||
|| canUnbindInvoices) ? (
|
||||
<div className="w-full space-y-2 md:w-72">
|
||||
{guardMessages.map((message) => (
|
||||
<p
|
||||
key={message}
|
||||
className="rounded-md border border-red-200 bg-white px-3 py-2 text-xs leading-5 text-red-700"
|
||||
role="alert"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
))}
|
||||
{invoice.amountEvidenceStatus === "manual_review" ? (
|
||||
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800">
|
||||
票面金额未自动识别,需由财务查看原件核对。
|
||||
</p>
|
||||
) : null}
|
||||
{invoice.amountEvidenceStatus === "ocr_failed" ? (
|
||||
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs leading-5 text-red-700">
|
||||
该发票的票面金额未能读取,暂时无法提交;请重新上传这张发票,或联系财务处理。
|
||||
</p>
|
||||
) : null}
|
||||
{canMaintainReimbursement && !invoice.linkedItemId ? (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-medium text-zinc-700" htmlFor={`invoice-item-link-${invoice.id}`}>
|
||||
选择报销明细
|
||||
</label>
|
||||
<select
|
||||
id={`invoice-item-link-${invoice.id}`}
|
||||
className="select-std w-full"
|
||||
value={invoiceItemSelections[invoice.id] ?? ""}
|
||||
onChange={(event) => setInvoiceItemSelections((previous) => ({
|
||||
...previous,
|
||||
[invoice.id]: event.target.value,
|
||||
}))}
|
||||
>
|
||||
<option value="">请选择明细</option>
|
||||
{detail.items.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.expenseType?.name ?? "费用"} · {item.description ?? formatMoney(item.amount)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!invoiceItemSelections[invoice.id] || linkInvoiceItemMutation.isPending}
|
||||
onClick={() => linkInvoiceItemMutation.mutate({
|
||||
invoiceId: invoice.id,
|
||||
linkedItemId: invoiceItemSelections[invoice.id],
|
||||
})}
|
||||
>
|
||||
{linkInvoiceItemMutation.isPending ? "关联中..." : "确认关联"}
|
||||
</Button>
|
||||
{invoiceItemLinkErrors[invoice.id] ? (
|
||||
<p className="text-xs leading-5 text-red-700" role="alert">
|
||||
{invoiceItemLinkErrors[invoice.id]}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{canUnbindInvoices ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={unbindInvoiceMutation.isPending}
|
||||
onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4" data-testid="reimbursement-attachments">
|
||||
{canMaintainReimbursement ? (
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<p className="text-xs text-zinc-500">支持 PDF/JPG/PNG/WebP,单次最多 5 个。</p>
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center">
|
||||
<label
|
||||
htmlFor="reimbursement-attachment-upload"
|
||||
className="inline-flex cursor-pointer items-center justify-center rounded-md border border-dashed border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-600 transition-colors hover:bg-zinc-100 hover:text-zinc-900"
|
||||
>
|
||||
{selectedAttachmentFiles.length
|
||||
? selectedAttachmentFiles.length === 1
|
||||
? selectedAttachmentFiles[0].name
|
||||
: `已选择 ${selectedAttachmentFiles.length} 个文件`
|
||||
: "选择附件"}
|
||||
</label>
|
||||
<input
|
||||
id="reimbursement-attachment-upload"
|
||||
className="hidden"
|
||||
type="file"
|
||||
accept="application/pdf,image/jpeg,image/png,image/webp"
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
if (files.length > MAX_REIMBURSEMENT_ATTACHMENT_FILES) {
|
||||
setSelectedAttachmentFiles([]);
|
||||
event.target.value = "";
|
||||
toast.error(`单次最多上传 ${MAX_REIMBURSEMENT_ATTACHMENT_FILES} 个附件,请分批上传。`);
|
||||
return;
|
||||
}
|
||||
setSelectedAttachmentFiles(files);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={!selectedAttachmentFiles.length || uploadAttachmentsMutation.isPending}
|
||||
onClick={() => uploadAttachmentsMutation.mutate(selectedAttachmentFiles)}
|
||||
>
|
||||
{uploadAttachmentsMutation.isPending ? "上传中..." : "上传附件"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(detail.attachments ?? []).length ? (
|
||||
<div className="space-y-2">
|
||||
{(detail.attachments ?? []).map((attachment) => (
|
||||
<div key={attachment.id} className="detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-11 min-w-0 flex-1 items-center gap-3 rounded-md text-left transition-colors hover:bg-white active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
|
||||
disabled={openAttachmentMutation.isPending}
|
||||
aria-label={`查看补充附件:${attachment.originalName}`}
|
||||
onClick={() => void handleOpenAttachment(attachment)}
|
||||
>
|
||||
<span className="inline-flex shrink-0 rounded-full bg-zinc-100 px-2 py-1 text-xs font-medium text-zinc-600">补充附件</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium text-zinc-900">{attachment.originalName}</span>
|
||||
<span className="mt-1 block text-xs text-zinc-500">
|
||||
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDisplayDateTime(attachment.createdAt)}
|
||||
</span>
|
||||
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
|
||||
</span>
|
||||
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" />
|
||||
</button>
|
||||
{canMaintainReimbursement ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={deleteAttachmentMutation.isPending}
|
||||
onClick={() => setDeleteAttachmentTarget({ id: attachment.id, originalName: attachment.originalName })}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : detail.invoices.length ? null : (
|
||||
<p className="text-sm text-zinc-500">暂无报销材料</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
</BusinessDetailMain>
|
||||
|
||||
<BusinessDetailAside>
|
||||
<BusinessDetailActionPanel
|
||||
status={<StatusBadge status={detail.status} />}
|
||||
primaryAction={primaryAction}
|
||||
secondaryActions={secondaryActions}
|
||||
moreActions={showSecondaryDetail ? (
|
||||
<div className="grid gap-2" data-testid="reimbursement-secondary-detail">
|
||||
{detail.status !== "paid" ? (
|
||||
<Button asChild variant="secondary">
|
||||
<Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{canDeleteReimbursement ? (
|
||||
<Button
|
||||
data-testid="reimbursement-delete-draft"
|
||||
disabled={deleteReimbursementMutation.isPending}
|
||||
onClick={() => setConfirmDeleteReimbursement(true)}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
删除草稿
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
testId="reimbursement-action-panel"
|
||||
>
|
||||
<ReimbursementActions
|
||||
status={detail.status}
|
||||
showSubmitAction={false}
|
||||
showWithdrawAction={isReimbursementParticipant}
|
||||
actionError={["reviewing", "approved"].includes(detail.status) ? actionError : ""}
|
||||
submitDisabled={blockStandardChecks.length > 0}
|
||||
submitHint=""
|
||||
submitNotice=""
|
||||
isPending={mutation.isPending}
|
||||
onSubmit={() => void handleSubmitAction()}
|
||||
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
|
||||
/>
|
||||
</BusinessDetailActionPanel>
|
||||
</BusinessDetailAside>
|
||||
</BusinessDetailWorkspace>
|
||||
|
||||
<ReimbursementDesktopWorkspaceShell model={workspaceModel} capabilities={workspaceCapabilities} slots={{ status: <StatusBadge status={detail.status} />, backAction: <Button asChild className="min-h-11 px-2" data-testid="reimbursement-back" variant="ghost"><Link replace to={backSource}><ArrowLeft aria-hidden="true" className="size-4" />{backLabel}</Link></Button>, headerDetails: workspaceHeaderDetails, beforeEvidence: workspaceBeforeEvidence, afterEvidence: workspaceAfterEvidence, actionPanel: workspaceActionPanel }} />
|
||||
<Dialog open={isInvoiceDialogOpen} onOpenChange={handleInvoiceDialogOpenChange}>
|
||||
<DialogContent
|
||||
className="max-h-[85vh] max-w-3xl overflow-y-auto"
|
||||
@@ -2005,6 +1372,6 @@ export function ReimbursementDetailPage() {
|
||||
deleteAttachmentMutation.mutate({ attachmentId: deleteAttachmentTarget.id, reason: deleteReason });
|
||||
}}
|
||||
/>
|
||||
</PageScaffold>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户